mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 16:19:28 +08:00
* feat(db): add repository policy normalization * feat(db): add repository policy binding * feat(db): apply repository read policy scope * feat(db): apply mutation policy scopes * feat(db): apply repository read field policy * feat(db): apply repository relation read policy * feat(db): apply relation read policy scope * feat(db): apply repository write field policy * feat(db): apply repository relation write policy * feat(db): apply repository create defaults * feat(db): enforce policy fields in read filters * feat(db): enforce policy fields in relation filters * feat(db): add connection repository policies * docs(db): add repository policy design proposal Record the design baseline for the Repository Policy work: the four-node model with its three invariants, a worked walkthrough, the parameter reference, the Knex execution details, and the phased implementation checklist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(db): settle the open policy decisions before implementation Record the calls that shape the implementation: the branch lands as a whole before merging, the `toWritePolicy` bridge stays until relation write targets force it apart, the `origin` marker stays internal to `@nocobase/db`, and scope keeps accepting builder callbacks because binding-time materialization already turns them into an AST. Also give the phase 1 acceptance list its missing landing point: the first five items assert on real SQL, which a unit test's fake adapter cannot do, so they belong in the cross-dialect contract suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(db): extract the policy enforcement helpers Move the policy code that had accumulated inside repository.ts into policy/enforce.ts, and lift `invalid` and `isPlainRecord` into a shared guards module so both files can use them. Behavior is unchanged; this only gives the enforcement layer somewhere to grow, and keeps the behavioral changes that follow readable on their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): make the read policy a required argument of every read The policy reached the read validators as trailing optional parameters, so honouring it was opt-in per call site. Seven of them did not: the returning `select` of createOne, createMany, updateOne, upsertOne, updateMany, deleteOne and deleteMany validated without it, and returned fields the read allowlist forbids. Relation aggregate branches dropped it the same way. PolicyReadContext replaces those optional parameters. It is required, a call site with no policy passes UNRESTRICTED_READ explicitly, and the compiler now lists every read surface that has not supplied one. That is what found the seven; the next read path added cannot repeat it. Descending into a relation goes through relationReadContext, which returns the target collection's own allowlist rather than threading two loose parameters, and rejects an unauthorized relation in one place. Also stop the policy scope from swallowing findOne's determinism guard. `findOne()` with neither filter nor sort has no defined answer, and a scope narrowing the rows does not give it one, so the guard now asks what the caller supplied rather than what the filter became. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): apply the read allowlist to every surface that names a field `read.fields` limited what a query returned, but the query itself leaks the same values: an `exists` with a range condition on `budget` returns no fields and still binary-searches the number out. The filter already enforced this. Sort, distinct, aggregate and groupBy did not have the parameter at all, so they named any column they liked. They take the read context now, and a relation path descends through relationReadContext so a sort or aggregate through a relation is judged against that relation's own allowlist. The cursor needs no check of its own: its axes are derived from the sort it requires, so the sort check covers it. Neither does groupBy's `having` or its sort, which run against a synthetic result collection whose every column came from `by` or `aggregate` and was checked there. The sort a query did not ask for stays exempt. Sort appends the primary key to make the order deterministic, which is the Repository's own condition rather than the caller's, so it is not judged against the allowlist that governs what the caller may name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): judge filter conditions by the collection they belong to The read allowlist check walked the whole filter tree against one collection's field list. A relation quantifier fell through to its nested conditions carrying the root's allowlist, so `tasks.some(...)` was judged by the projects allowlist. Both directions were wrong: a condition on a tasks field passed whenever the root happened to have a field by that name, and a field the relation node explicitly authorized was rejected because the root did not have it. Worse, the relation itself was never checked. A filter could traverse a relation absent from `read.relations` and reason about rows through it. The walk now carries the collection and the policy node together, descending through relationReadContext, and intersects each relation branch with that relation's own scope in the same pass. A quantifier therefore only ever reasons about rows the caller may see: `none(...)` means no visible match, `notEmpty` means at least one visible row. Caller conditions and policy conditions stay apart structurally rather than by an origin tag on the node: they are two separate values, only the caller's is walked, and the merge happens after. See decision 0.5 and task 1.5 in the roadmap for why that replaced the tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(db): add the cross-dialect policy contract suite The claims phase 1 makes are about SQL, and a unit test cannot check them: its adapter is a stub, so it sees the plan the repository built, never the statement the database ran or the rows that came back. These run against every dialect. Three suites. Pushdown asserts the scope reaches the WHERE clause rather than being applied to a full read, that a caller's or-group is intersected with it rather than flattened into it, and that bulk mutations and `all: true` are narrowed too. Indistinguishability asserts that a row belonging to another tenant and a row that never existed produce the same answer from every method, since any difference between them lets a caller enumerate another tenant's keys. Read shape asserts that a forbidden field is refused wherever it is named, not quietly trimmed, and that scoping an expanded relation hides its rows without dropping the root records. The fixture seeds through the Repository rather than raw SQL so it does not have to know each dialect's physical column names. Verified on SQLite; the remaining seven dialects need their services and have not been run here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(app-server): give the policy error codes their HTTP status The codes were added to the error enum without a status, so they fell through to the 400 default. A refusal reported as 400 tells the caller their request was malformed when it was in fact understood and denied, and there is nothing for them to fix in the payload. The read refusals and SCOPE_VIOLATION answer 403; RECORD_OUTSIDE_SCOPE, which an upsert raises when the target exists outside the scope, answers 409 like the other conflicts. A scope that simply does not match never reaches this table. It stays a 404 or an empty result, so that forbidden and absent remain one answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): derive a policy-bound connection instead of building a second one `withPolicies` constructed a whole new KnexDatabaseConnection. Three things came of that, and binding is meant to happen once per request, so all three were on the hot path. It passed `undefined` where the transaction invalidation collector goes, so a connection bound inside a transaction stopped deferring collection invalidation to the commit and ran relation validation eagerly. It built a second collection registry, whose cache starts empty, so the first read through a bound connection re-introspected the schema. And it passed a `knexInstance` that is undefined until the connection is first used, so binding before connecting produced a connection that opened its own pool, which the original could not close. PolicyBoundConnection delegates instead. One connection, one registry, one pool, and transactions run on the connection that knows how to set them up, so nothing has to be copied across correctly. `withPolicies` now returns ScopedDatabaseConnection, which has no `withPolicies` of its own: bindings can be narrowed later but not replaced. `repository()` still returns an unbound Repository for a collection the map does not cover, which is what `requireScope` on the Collection is for rather than something the type can promise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(db): hold the record to its scope after the write A scope in the WHERE clause decides which rows an operation may touch. It says nothing about where those rows end up, so a policy that let a caller write the scope field let them move a record out of their own tenant, and `create.scope` was normalized and then never read at all — a caller could create records in someone else's tenant outright. Both are the escalation invariant 2 exists to stop. The check runs in the database rather than in memory. An in-memory evaluator has to reproduce the engine's own comparison semantics, and one of those cannot be reproduced: the same string equality matches under MySQL's default collation and not under PostgreSQL's, and the evaluator cannot see which column it is looking at. A statement issued inside the transaction against the locked row agrees with the WHERE clause that selected it by construction, which also retires the differential testing that an evaluator would have needed. It stays cheap because a write that touches none of the fields the scope reads cannot move a record out of it, so the check is skipped entirely — renaming a project under a tenant scope costs exactly what it did before. Only a write that reaches a scope field pays for one primary-key lookup, and that same rule decides when updateMany has to lock its rows instead of issuing one statement. A create is always judged, having no prior state to compare against. upsertOne gains the three branches the design asks for. It also stops merging the scope into its filter: an upsert locates its target by a unique key, and a scope merged in left a filter that was no longer one key, so binding any non-trivial update scope made every upsert fail with INVALID_UNIQUE_SELECTOR. Whether the caller may touch the target is now asked separately, of the record that is actually there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): finish the policy normalization contract Four things the normalization layer left open. A scope now has to stay on this collection's own scalar columns. A relation path or quantifier raises the question a scope cannot answer — whether the target's own policy applies — and would put a join into every query that the caller cannot see; a JSON operator stops a scope being a plain comparable condition. The restriction is about what a scope should mean rather than what can be evaluated, since the write-back check is a statement and would handle either. The scope AST is frozen all the way down. `explainPolicy` hands it out, and freezing only the root left the conditions inside it mutable, so a caller could have quietly changed the effective range of every later query on that repository. A Date used as a create default is copied and frozen for the same reason. `create.scope` is checked for satisfiability once the collection resolves. A scope reading a field that is absent from `create.fields` and `create.defaults` and has no column default describes a create that can never succeed: every call inserts, fails the write-back check and rolls back, reporting an error about `values` that the caller cannot act on. A field that does have a column default is accepted — whether that default satisfies the condition is a comparison only the database can make, and the write-back check makes it. The normalized scope is resolved once per repository instead of on every write. A bound policy is frozen, so walking the collection metadata again each time bought nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(db): let a bound policy be narrowed further `narrow` completes the binding API: a request that has already bound a policy can tighten it for one piece of work — a background job that may only read its own rows, a handler that must not touch relations — without being able to loosen anything. Every rule only takes away. Scopes are ANDed, field lists and relations intersected, and `false` on either side wins, so there is no spelling that widens and a narrowed Repository cannot climb back out. A patch is still a patch: a member it does not mention stays as it was, and one it does mention is intersected, which makes `relations: {}` a way to say "none of them". Relation write operations intersect rather than merge. Each of them is an explicit grant, so keeping one the patch did not name would let a patch preserve a permission it never asked for. `scope` is optional in a patch although it is required when a policy is first bound. Those are not in tension: binding has to say everything out loud because a half-written policy reads as configured while leaving the rest open, whereas an omitted member in a patch asks for no change, which can only keep or tighten what is in force. Also refresh the public API baseline, which the policy exports had left stale since the first commit on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(db): expand policy references when the binding is made `PolicyRef` was a type with no way to produce one and no code to resolve one: a reference reaching `read.relations` was rejected outright as "not resolved by Policy". `ref(target)` now exists and is expanded when `withPolicies` binds the map. A reference resolves against that map rather than against a policy the target Collection owns elsewhere. That keeps reuse explicit: reached through a relation and queried directly stay two separate declarations, which is what makes either of them possible to reason about, while `ref` gives the one case where repeating a node would drift. Expansion happens once at binding, so a cycle, a target the binding does not cover, and a target whose read is `true` or `false` rather than a set of rules are all reported then, naming the chain. Dereferencing per query would have turned the first of those into a stack overflow mid-request. Only `read.relations` carries references. Relation writes are authorized by the originating side alone, with the target's own policy deliberately not taking part, so there is nothing there to refer to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): keep a foreign key and its relation in step A policy that authorized `owner { id }` while leaving `ownerId` out of `read.fields` refused the column and handed the same value back through the relation. The refusal protected nothing and only made the allowlist read as stricter than it was. The context now separates two things that were one list. `fields` is the declared allowlist and still decides what a query with no select returns. `readableFields` is what a caller may name, which adds the foreign key of every authorized relation that actually returns the key it points at — so a relation granted no fields, or granted fields that do not include the target key, gives nothing away and its foreign key stays hidden. The implication runs one way only. A readable `ownerId` says nothing about whether `owner` may be expanded, because the target carries other fields. Resolving a relation's policy now takes the target collection rather than the parent, which it needed anyway: the node governs the target, so its own relations have to be looked up there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(db): scope the targets a relation write may reach A root scope constrains the root record. A relation operation reaches its target straight from the target table by selector, so it was not constrained at all: a caller confined to their own tenant could `connect` another tenant's task into their project, or `update` and `delete` one through the relation. The root scope stopped them touching the wrong project, not the wrong task. `RelationWriteNode.scope` now reaches the queries that locate a target. The policy's relation tree is resolved against the collection graph into a scope tree that travels alongside the mutation AST, and the adapter intersects it with each locating query. A relation reached through more than one nested shape gets every scope ANDed, since none of them granted more than the others. A created target is deliberately not constrained: its ownership comes from the relation key, so it necessarily lands under the originator. Its own nested relations still are. An out-of-scope target raises the same error as one that does not exist, rather than a distinct code. Telling them apart would rebuild the existence oracle the root scope closes, this time reachable through a relation. The `toWritePolicy` bridge survives after all. The scope travels beside the legacy structure rather than through it, so "a relation scope has nowhere to live in the old shape" is no longer what forces the bridge apart; roadmap decision 0.9 is updated to say what does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(app-server): let a route action declare a repository policy Every action now accepts a `policy`, which is normalized when the routes are defined — a malformed one fails then rather than on whichever request first reaches it — and bound to the Repository the handler runs against. A caller cannot supply one. `readInput` already validates the body key by key against a per-action allowlist, and neither `policy` nor `scope` appears on any of them, so both were refused before this change; the tests now pin that down, since the leak would otherwise reopen silently the next time someone widens a list. Deny-by-default is deliberately not part of this. Flipping the default would invalidate every route declaration that exists today, in this repository and in consumers, so it belongs in the migration that updates them rather than ahead of it. The roadmap records that. `RepositoryOperations` is the type a handler takes now: a plain and a policy-bound Repository share their operations but neither is a subtype of the other, since `withPolicy` must not be reachable once a policy is bound and `narrow` means nothing before one is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(db): degrade the return type when a read policy is bound A policy-bound read does not return the whole record: with no select it returns `read.fields`, and an omitted `fields` is an empty allowlist rather than a free pass. The type said otherwise, so every caller of a scoped Repository was handed a `TRecord` whose fields might not be there. `withPolicy` captures the policy literal through a `const` type parameter, and `PolicyRecord` degrades the record to `Partial<TRecord>` whenever `read` is a set of rules. `read: true` adds no limits and keeps the full type. Spike 0.6 asked whether the literal could drive this, and it can — but the judgement is coarser than the spike proposed. It asks whether `read` is a rule object rather than whether `fields` is a string array, because omitting `fields` is the same empty allowlist and deserves the same type. Narrowing further, to exactly the listed fields, would need the literal array to survive inference at every call site; a `Partial` that is honest beats a precise type that widens the moment someone stores the policy in a variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(db): bring the policy documents up to what was built The status banners still read "proposal, not yet implemented" while phases 1 through 3 had landed. They now say what is in place, where the implementation and the cross-dialect contract tests live, and which decisions came out differently from the design — the write-back check done in SQL rather than in memory, caller and policy conditions kept apart structurally rather than by an origin tag, an out-of-scope relation target sharing its error with a missing one. The migration is recorded as not started, and the reason is stated plainly: removing `writePolicy` means changing four kinds of consumer at once, and deleting it first would break every route declaration and plugin that exists today. Both semantics therefore coexist for now, which is exactly the shape the design warns about, so the note says so rather than letting it look settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(db): add a Policy quick start The policy documents under `proposals/` are a design record: they explain why each rule exists, at a length that suits deciding the rules rather than using them. Nothing told someone with a multi-tenant table what to write. This walks through binding a policy, what reads and writes actually do, what going out of scope looks like from the caller's side, and the four mistakes the design makes easy: reading an omitted `fields` as "no limit", expecting `scope` to fill values into a new record, putting a relation path in a scope, and writing a `create.scope` over a field the create can never set. `write-policy.md` and the Repository overview now point at it, since "limit which fields" and "limit which rows" are easy to confuse and the answer is a different API. Writing it turned up a real gap, fixed here: a scope written as `{ 'owner.tenantId': 'T1' }` kept the dotted name as a single path segment, so the check that rejects relation paths never saw it and the policy bound successfully, only to fail on every query as an unknown field. `fields` already refused dotted names; scope now agrees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): stop relation writes escaping their policy scope The relation scope reached the queries that *locate* a target but not the ones that *change* it. Five ways out, one root cause, all five reported by the PR review and each now pinned by a test that failed before this commit. A to-one relation stores its foreign key on the root table, so `owner.connect` writes `ownerId` without ever naming it in `values`. The root write-back check only inspected the submitted values, so a caller could carry their own record into another tenant through a relation and commit — the one outright escalation of the five. The check now also asks whether any relation being written is a to-one whose foreign key the scope reads. `belongsTo` + `set` is resolved while the row is still being built, a different path from every other relation operation, and it was passing no scope at all: `create.relations.<field>.scope` did not apply to it. Detaching is still reaching for a row. `disconnect` on a to-one cleared whatever was attached, and `set` removed every current target not in the desired list, neither consulting the scope — so a caller could let go of rows they were never allowed to see. Both are now restricted to targets the scope can locate, and a to-one target it cannot locate raises the same error as one that does not exist, keeping forbidden and absent indistinguishable. Invariant 2 applies to a relation target as much as to a root record: locating it inside the scope says nothing about where the submitted values leave it. A relation `update` is judged again afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): stop the policy refusing its own configuration Two legitimate configurations were rejected. A relation `combine` branch passed `scope.filter` down as the filter every branch inherits, and that value already carries the relation scope. The recursive validation then walked it as caller conditions, so a scope field the read allowlist omits — `tenantId`, in every multi-tenant policy — failed with FIELD_READ_FORBIDDEN naming a field the caller never wrote. The branch now inherits the caller's own filter; the scope is re-applied by each recursion regardless, so nothing is lost by leaving it out. This is the case the design warned about and I claimed did not apply. The separation of caller and policy conditions is structural, but `combine` fed an already-merged filter back into validation, which is exactly the ordering assumption the warning was about. Narrowing `create.defaults` raised "Unsupported Policy option: defaults" because the shared write-node helper normalized every patch as an `update`, and only `create` accepts defaults. The node kind travels with the patch now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(db): stop the policy promising more than it enforces Four places where the guarantee and the behavior had drifted apart. A Repository from a policy-bound Connection is still a DefaultRepository and the Connection types it as `Repository`, so `withPolicy` stayed reachable and replaced the binding outright — handing back an unrestricted Repository and undoing the Connection's authorization. Rebinding is refused now; `narrow` remains the way to tighten. `PolicyRecord` asked whether the whole `read` union is an object. A policy held in a variable of type `RepositoryPolicy<T>` has `read: true | false | ReadNode`, which is not, so the type handed back the complete record for exactly the policies most likely to restrict it. It now asks whether the union contains a rule node. `through` accepted anything truthy and read it as an empty allowlist, so `through: true` — the obvious way to write "allow a payload" — silently became a different permission, and unknown keys inside the rule passed unnoticed. It is `false` or a rule object, and anything else is refused. `Object.freeze` does not reach a Date's internal time, and `explainPolicy` returned the very object the writes read from, so a caller could change a server-assigned create default after binding. The policy is handed out with its Date defaults copied. Finally, `validateMutation` for a create ran no scope check, so a policy whose `create.scope` can never be satisfied was reported valid and only failed later through an insert that rolls back every time — which is the opposite of what that method is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(db): correct the execution notes, and catch a split changeset `policies-internals.md` was the one document the implementation changes never reached. It still described an `origin` tag on merged filter nodes and an in-memory `evaluateScope` with a `hasWriteTriggers` fallback, neither of which exists — so a reader following it would have built toward a representation the code cannot produce. It now describes what runs: caller and policy filters kept apart structurally, and a write-back check issued as a statement. Two things are recorded that the original could not have known. The structural separation failed once, in the relation `combine` branch, and the note says how; and the check has a second way into the root record, since a to-one relation writes its foreign key there without ever appearing in `values`. `policy-http-action-slot.md` carried two frontmatter blocks. Changesets parses only the first, so the `@nocobase/db` half was body text and that package would not have been released from this file. The repository's own validator did not notice, because it reads the first block and never looks at the body; it does now, and refuses a stray declaration block with an explanation of why it silently fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(app-server): give an exposure one policy instead of nine The Repository API had two authorization mechanisms side by side, and the older one stood in front of the newer: `writePolicy` defaulted to `false` when an action omitted it, so an action that declared only a `policy` was refused before the Policy it declared ever ran. The slot was usable for reads and dead for writes. `writePolicy` is gone from a route declaration. `policy` moves from the action to the exposure and is required. An action now says only that an endpoint exists; what it may do is the exposure's Policy, which governs reading, creating, updating and deleting together. Requiring it is the point. `writePolicy` refused writes when it was missing, while a missing Policy restricts nothing, so an optional `policy` would have turned every declaration that exists today from "refuse every write" into "allow everything" with nothing to see in the diff. Putting it on the exposure rather than the action is what makes that affordable: `RepositoryPolicy` requires all four nodes, and per action that meant nine copies per collection that could disagree with one another. It also settles a conflict that had no good answer -- `read` governs the record a write returns, so a `createOne` action carrying `read: false` failed the create it was meant to permit. A Policy may now be a function of a principal, paired with a `principal(context)` resolver the application supplies, which is what lets a scope name the caller. Without it a Policy can only express a constant scope, and multi-tenancy -- the reason Policies exist -- stays unwritable; the slot would have been a wordier `writePolicy`. A resolver that returns nothing refuses the request with 403 rather than binding a Policy built from a principal that is not there, and a Policy function without a resolver fails at declaration. What that costs is checking time: a fixed Policy is still normalized when the routes are defined, but one built per request can only be validated per request. Such a failure is `INVALID_POLICY`, and it now propagates as a server error instead of a 400 -- a Policy is server-owned, so its mistakes are not the caller's to be told about in those terms. `ref()` is refused outright on both paths: it resolves against a `withPolicies` map, and these routes bind one Policy per exposure, so an unexpanded reference would reach a request as a 403 on a relation the Policy appears to grant. `@nocobase/db` gains `buildRepositoryPolicy`, whose unmentioned nodes are denied. Without it the four-node requirement would have meant writing `delete: false` into every exposure, and the one declaration with seven relation operations open would have been unreadable as a literal. Two defects surfaced while rewriting the tests and are fixed here. A `false` write node was resolved after the payload had been normalized, so `update: false` with `values: {}` reported 400 for invalid input rather than 403 for a write that was never permitted -- the old option short-circuited on the first line and the Policy did not. And a `create` node granting `update`, `upsert`, `disconnect`, `set` or `delete` on a relation was accepted although a root create performs none of them; that check lived in the HTTP layer and now runs during normalization, for every consumer. File exposures declare a Policy too, and it reaches uploads. An upload supplies no caller fields at all, so it binds a Policy derived from the exposure's: `create.scope` and `create.defaults` are inherited and the field allowlist is replaced by the file columns. A file uploaded under a scoped Policy therefore lands inside the scope the same exposure reads from, instead of vanishing from the listing that just accepted it. Only the upload writes are bound -- the Repository the manager hands back is the plugin's server-side API, as trusted as a `db.repository()` call, and binding it would restrict that rather than the HTTP surface. The public content route under `accessPath` is unchanged and deliberately outside all of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.3 KiB
JavaScript
118 lines
4.3 KiB
JavaScript
// 校验 .changeset/*.md 的格式。这一层是阻塞性的:文件本身写错了,机器能
|
||
// 确定判断,没有让它流到发版时才炸的理由。
|
||
//
|
||
// 只检查已经存在的文件是否合法,不判断「该不该有 changeset」——那是
|
||
// require-changesets.mjs 的职责。
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const root = process.cwd();
|
||
const changesetDir = path.join(root, '.changeset');
|
||
const VALID_BUMPS = new Set(['patch', 'minor', 'major']);
|
||
|
||
// packages/ 是两层的:packages/<分类>/<包>,分类目录本身没有 package.json。
|
||
// 只读一层会得到空集合,于是每个 changeset 里的包名都会被判成「未知的包名」。
|
||
function readWorkspacePackages() {
|
||
const packagesDir = path.join(root, 'packages');
|
||
const names = new Set();
|
||
if (!fs.existsSync(packagesDir)) return names;
|
||
for (const category of fs.readdirSync(packagesDir, { withFileTypes: true })) {
|
||
if (!category.isDirectory()) continue;
|
||
const categoryDir = path.join(packagesDir, category.name);
|
||
for (const entry of fs.readdirSync(categoryDir, { withFileTypes: true })) {
|
||
if (!entry.isDirectory()) continue;
|
||
const manifest = path.join(categoryDir, entry.name, 'package.json');
|
||
if (!fs.existsSync(manifest)) continue;
|
||
names.add(JSON.parse(fs.readFileSync(manifest, 'utf8')).name);
|
||
}
|
||
}
|
||
return names;
|
||
}
|
||
|
||
const known = readWorkspacePackages();
|
||
const errors = [];
|
||
|
||
if (!fs.existsSync(changesetDir)) {
|
||
console.log('没有 .changeset 目录,跳过。');
|
||
process.exit(0);
|
||
}
|
||
|
||
// 也要扫 .changeset/pre/。prerelease 模式下 `changeset version` 每次都会把整个 pre/ 目录重新读一遍来计算累积 bump,所以里面的包名同样必须在 workspace 里存在。删包时漏改这里,本地和 CI 都不会报错,一直到发版那一刻才炸成 `Found changeset ... which is not in the workspace`。
|
||
function collectChangesetFiles() {
|
||
const files = [];
|
||
for (const dir of [changesetDir, path.join(changesetDir, 'pre')]) {
|
||
if (!fs.existsSync(dir)) continue;
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (!entry.isFile()) continue;
|
||
if (!entry.name.endsWith('.md') || entry.name === 'README.md') continue;
|
||
files.push(path.join(dir, entry.name));
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
const files = collectChangesetFiles();
|
||
|
||
for (const full of files) {
|
||
const file = path.relative(changesetDir, full);
|
||
const raw = fs.readFileSync(full, 'utf8');
|
||
|
||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||
if (!match) {
|
||
errors.push(`${file}: 缺少 YAML frontmatter`);
|
||
continue;
|
||
}
|
||
|
||
const body = raw.slice(match[0].length).trim();
|
||
if (!body) {
|
||
errors.push(`${file}: 缺少变更说明正文`);
|
||
}
|
||
|
||
// changesets 只解析第一个 frontmatter 块。正文里再出现一个包声明块时,
|
||
// 那半截会被当作说明文字塞进前一个包的 CHANGELOG,而它声明的包根本不会
|
||
// 被发布——不报错,只是静默失效,所以在这里挡住。
|
||
const strayBlock = body.match(
|
||
/^---\r?\n\s*(?:"[^"]+"|'[^']+'|[^:\n]+)\s*:\s*(?:patch|minor|major)\b/m,
|
||
);
|
||
if (strayBlock) {
|
||
errors.push(
|
||
`${file}: 正文里出现了第二个 frontmatter 块。changesets 只解析第一个,多出来的声明会被当成说明文字而不会发布;请把所有包合并进同一个块`,
|
||
);
|
||
}
|
||
|
||
const lines = match[1].split(/\r?\n/).filter((l) => l.trim());
|
||
if (!lines.length) {
|
||
errors.push(`${file}: frontmatter 为空,至少要声明一个包`);
|
||
continue;
|
||
}
|
||
|
||
for (const line of lines) {
|
||
const entry = line.match(
|
||
/^\s*(?:"([^"]+)"|'([^']+)'|([^:]+?))\s*:\s*(.+?)\s*$/,
|
||
);
|
||
if (!entry) {
|
||
errors.push(`${file}: 无法解析的声明 -> ${line.trim()}`);
|
||
continue;
|
||
}
|
||
const name = (entry[1] ?? entry[2] ?? entry[3]).trim();
|
||
const bump = entry[4].replace(/^["']|["']$/g, '').trim();
|
||
|
||
if (!known.has(name)) {
|
||
errors.push(`${file}: 未知的包名 "${name}"`);
|
||
}
|
||
if (!VALID_BUMPS.has(bump)) {
|
||
errors.push(
|
||
`${file}: 非法的 bump 类型 "${bump}"(只允许 patch / minor / major)`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (errors.length) {
|
||
console.error('changeset 校验失败:\n');
|
||
for (const e of errors) console.error(` - ${e}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`changeset 校验通过(${files.length} 个文件)。`);
|