@starci/hfs 4.7.35 → 4.7.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5598,3 +5598,33 @@ rules:
5598
5598
  source: owner
5599
5599
  quote: "apps on Grammar write layout-only CSS; every visual element lives in @starci/grammar"
5600
5600
  ruling: "ANATOMY-2 owns contextual field defaults, PADDING-0 owns panel list alignment, and COLLECTION-3 owns composite member avatars for conversation and group identity. Grammar supplies these capabilities; the FE canon rejects static field variants on either layer, primary Tabs inside painted surfaces, raw app surface paint and stacked horizontal list-wrapper padding inside painted surfaces."
5601
+
5602
+ - id: R319
5603
+ code: "FE_BUTTON_INTENT"
5604
+ law: "An app Button or IconButton declares an intent and never a variant; Grammar resolves the variant from intent and placement (CTA-6). A destructive confirm lives in a confirmation dialog, a render holds at most one commit, and a collection item holds none."
5605
+ kinds: [codemod, lint, design]
5606
+ gates: [pre-commit, settle, land, ci]
5607
+ failureCodes: ["FE_BUTTON_INTENT", "FE_BUTTON_INTENT_REQUIRED", "FE_BUTTON_NO_VARIANT", "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG", "FE_SINGLE_COMMIT_PER_RENDER", "FE_NO_COMMIT_IN_COLLECTION_ITEM"]
5608
+ enforcers:
5609
+ - {kind: eslint-fe, id: button-intent-required}
5610
+ - {kind: eslint-fe, id: button-no-variant}
5611
+ - {kind: eslint-fe, id: destructive-confirm-in-dialog}
5612
+ - {kind: eslint-fe, id: single-commit-per-render}
5613
+ - {kind: eslint-fe, id: no-commit-in-collection-item}
5614
+ editions: [lite, full]
5615
+ docs:
5616
+ - knowledge/ui/composition/cta.yaml
5617
+ - docs/code-pattern-enforcement.md#r319-button-intent
5618
+ proves:
5619
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5620
+ exemplifiedBy:
5621
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5622
+ witness:
5623
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5624
+ templateNa: "The intent of an action comes from the product decision in the .starciwork specification; a template cannot choose it."
5625
+ cases:
5626
+ - id: case-1
5627
+ date: "2026-10-25"
5628
+ source: owner
5629
+ quote: "Critique this carefully so the button choice is a system and not a feeling, understand? Update .claude."
5630
+ ruling: "The button variant follows the declared intent (owner ruling button-variant-follows-intent): the app passes intent and step, Grammar derives the variant from them and from the placement it reads from React context. The semantics are HeroUI's, so the lint is universal in the FE canon."
@@ -50,10 +50,11 @@ rules:
50
50
  - id: case-3
51
51
  when: A list sits inside a panel or its rows own their own inset
52
52
  owner: The list component
53
- render: "Owner ruling 2026-10-25 (ruling:R318/case-1): row content, section captions and headers align with the
54
- panel content edge while row backgrounds may extend into the panel gutter. Compose without padding
55
- wrappers. The selected Grammar supplies the panel-alignment API; the universal FE canon reports
56
- stacked padding as FE_LIST_PANEL_PADDING."
53
+ render: "Owner ruling 2026-10-25 (owner:panel-list-box-edge): the row box aligns with the panel content
54
+ edge, like the search field and tabs boxes. Content retains the vendor item inset and rounded
55
+ interaction background inside that box; the list keeps its vendor inset without negative-margin
56
+ bleed. Compose without padding wrappers. The selected Grammar supplies the panel-alignment API;
57
+ the universal FE canon reports stacked padding as FE_LIST_PANEL_PADDING."
57
58
  notThisRule: The boundary protecting readable content keeps its one owned inset from the selected Grammar scale.
58
59
  - id: PADDING-1
59
60
  title: Minimal clearance
@@ -2155,6 +2155,38 @@ FE_BLOCK_PRODUCT_HOOK_DEFINITION:
2155
2155
  owner: other-op:interface.implement
2156
2156
  kind: check-finding
2157
2157
 
2158
+ FE_BUTTON_INTENT:
2159
+ title: "Button variant is chosen by hand instead of by intent"
2160
+ title_vi: "Variant của nút được chọn thủ công thay vì theo intent"
2161
+ meaning_vi: "Button hoặc IconButton của ứng dụng thiếu intent, truyền variant, đặt nút xác nhận xoá ngoài hộp thoại xác nhận, có nhiều hơn một nút commit trong một lần render, hoặc đặt nút commit trong từng phần tử của danh sách."
2162
+ causes_vi:
2163
+ - "Truyền variant thay vì intent cho Button hoặc IconButton"
2164
+ - "Đặt intent destructive với step confirm ngoài AlertDialog hoặc ConfirmDialog"
2165
+ - "Đặt hai nút commit trong cùng một lần render hoặc một nút commit trong mỗi thẻ của danh sách"
2166
+ nextStep_vi: "Khai báo intent (và step cho destructive) theo hệ quả của hành động, bỏ variant để Grammar tự suy ra, và chạy codemod button-intent cho các nút cũ."
2167
+ owner: "op-retry"
2168
+ kind: "check-finding"
2169
+
2170
+ FE_BUTTON_INTENT_REQUIRED:
2171
+ title: "A button has no declared intent"
2172
+ title_vi: "Nút chưa khai báo intent"
2173
+ meaning_vi: "Button hoặc IconButton tại <file> chưa nêu ý định của lệnh."
2174
+ causes_vi:
2175
+ - "Bỏ intent nên Grammar không có dữ kiện để chọn variant."
2176
+ nextStep_vi: "Khai báo intent theo hệ quả và thứ hạng của lệnh trong quyết định (CTA-6)."
2177
+ owner: op-retry
2178
+ kind: check-finding
2179
+
2180
+ FE_BUTTON_NO_VARIANT:
2181
+ title: "An app button chooses a visual variant"
2182
+ title_vi: "Nút ứng dụng tự chọn variant"
2183
+ meaning_vi: "Button hoặc IconButton tại <file> truyền variant thay vì để Grammar suy ra từ intent và vị trí."
2184
+ causes_vi:
2185
+ - "Ứng dụng truyền variant trực tiếp hoặc qua props spread."
2186
+ nextStep_vi: "Thay variant bằng intent theo hệ quả của lệnh (CTA-6)."
2187
+ owner: op-retry
2188
+ kind: check-finding
2189
+
2158
2190
  FE_CALLBACK_SHAPE:
2159
2191
  title: "An auth callback performs more than code exchange and redirect"
2160
2192
  title_vi: "Callback xác thực làm thêm việc ngoài đổi mã và chuyển hướng"
@@ -2306,6 +2338,16 @@ FE_DANGEROUS_HTML:
2306
2338
  owner: op-retry
2307
2339
  kind: check-finding
2308
2340
 
2341
+ FE_DESTRUCTIVE_CONFIRM_IN_DIALOG:
2342
+ title: "A destructive confirm is outside its confirmation dialog"
2343
+ title_vi: "Lệnh xác nhận phá hủy nằm ngoài hộp thoại xác nhận"
2344
+ meaning_vi: "Nút destructive confirm tại <file> không nằm trong nội dung AlertDialog hoặc ConfirmDialog cùng tệp."
2345
+ causes_vi:
2346
+ - "Đặt lần nhấn cuối ngoài hộp thoại hoặc trên trigger."
2347
+ nextStep_vi: "Đặt confirm trong nội dung hộp thoại cùng tệp; trigger dùng step entry (CTA-6)."
2348
+ owner: op-retry
2349
+ kind: check-finding
2350
+
2309
2351
  FE_EFFECT_CLEANUP:
2310
2352
  title: "What an effect starts, its cleanup releases"
2311
2353
  title_vi: "Effect khởi động thứ không được dọn"
@@ -2576,6 +2618,16 @@ FE_NO_ALERT:
2576
2618
  owner: op-retry
2577
2619
  kind: check-finding
2578
2620
 
2621
+ FE_NO_COMMIT_IN_COLLECTION_ITEM:
2622
+ title: "A collection item repeats a commit"
2623
+ title_vi: "Phần tử danh sách lặp lại lệnh commit"
2624
+ meaning_vi: "Một phần tử collection hoặc callback map tại <file> hiển thị nút commit."
2625
+ causes_vi:
2626
+ - "Mỗi hàng giành vai trò hành động chính của toàn màn hình."
2627
+ nextStep_vi: "Dùng alternative cho hành động tiếp theo của hàng và giữ commit ở quyết định của màn hình (CTA-6)."
2628
+ owner: op-retry
2629
+ kind: check-finding
2630
+
2579
2631
  FE_NO_TESTS:
2580
2632
  title: "Front end holds a test file, test tooling or a test dependency"
2581
2633
  title_vi: "Front end có tệp test, công cụ test hoặc thư viện test"
@@ -2811,6 +2863,16 @@ FE_SERVICE_ROLE_FORBIDDEN:
2811
2863
  owner: op-retry
2812
2864
  kind: check-finding
2813
2865
 
2866
+ FE_SINGLE_COMMIT_PER_RENDER:
2867
+ title: "A component renders more than one commit"
2868
+ title_vi: "Component hiển thị nhiều hơn một lệnh commit"
2869
+ meaning_vi: "Một đường render của component tại <file> có nhiều nút commit cùng lúc."
2870
+ causes_vi:
2871
+ - "Nhiều lệnh cùng nhận thứ hạng hành động tiếp theo."
2872
+ nextStep_vi: "Giữ một lệnh tiếp theo là commit và phân loại các lệnh còn lại theo hệ quả (CTA-6)."
2873
+ owner: op-retry
2874
+ kind: check-finding
2875
+
2814
2876
  FE_SIZE_AND_STATE_BUDGET:
2815
2877
  title: "Hook and state budgets; no hand-written poll loop"
2816
2878
  title_vi: "Vượt ngân sách hook và state"
@@ -84,7 +84,7 @@ rulings:
84
84
  critic-coverage: "Who judges each op's product is declared once, in the required judge field of its contract (modules/kernel/op-judges.yaml): machine, critic, owner or next-leg, never nobody. An independent Critic of another provider, on the frontier tier, runs for drawing (inside the op) and, run by the runtime at settle, for scope.define, business.decide and architecture.decide (modules/kernel/critic.yaml coverage); the op-judge check holds that table and the contracts to each other."
85
85
  debug-is-time-boxed: "Debug is a time-boxed auditor of role conformance for the stabilisation phase, bound to a chat session; the runtime is self-sufficient, every edge case Debug meets becomes a rule plus an edge-case entry, and the loop is set up no more once the end condition of the debug role holds."
86
86
  updating-the-runtime: "The runtime tree can be updated while workflows run, and no running workflow conflicts with the update. What a change asks of each role is declared once in modules/kernel/revision-scope.yaml and checked against the tree. A change that concerns no role costs no seat anything. A live seat is updated in place by default: woken once per revision change, even with an empty menu, with exactly the changed files that concern it, it reads and attests them (one wake, never a second for the same revision; several commits deployed together are one change). A seat is replaced by a fresh one that boots from the stores only when a rule of its contract was removed or reversed (a line deleted or modified in its declared contract files; a commit may declare an edit wording-only, and the declaration holds only for a file that keeps its structure), when the files its launch prompt is built from changed, or when it is already due for rotation (the revision change folds into that replacement). A replacement happens at the seat's next yield, never in the middle of a decision. An Op is neither updated nor restarted inside an attempt: it keeps the rules of its admission, the next try and the next Critic run are admitted under the new rules, and only guards bite at once. The engine restarts only for the files its process loaded, derived from its import graph; every other command is a new process that reads the tree."
87
- debug-fix-lanes: "A runtime defect Debug catches is fixed at once, never queued behind another: Debug splits it by independent cause and opens one fix lane per cause in the same turn, each in its own clone with the files it may touch named, so the lanes run in parallel; each lane's fix reaches the host as soon as `starci runtime verify` prints `verified <sha>` for it (the check, the package clean-test and the scaffold e2e on one commit; the line of `npm run check` alone runs no test and is never the report), through `starci runtime deploy --from <clone>` (one verified revision change; never a hand fast-forward of the live checkout followed by a hand restart). Nothing a lane found is left listed as later: it is fixed in that round or handed to another lane opened then. The fixes of a day are consolidated into one release cut at the end of that day."
87
+ debug-fix-lanes: "A runtime defect Debug catches is fixed at once, never queued behind another: Debug splits it by independent cause and opens one fix lane per cause in the same turn, each in its own clone with the files it may touch named, so the lanes run in parallel; each lane's fix reaches the host as soon as `starci runtime verify` prints `verified <sha>` for it (the check, the package clean-test and the scaffold e2e on one commit; the line of `npm run check` alone runs no test and is never the report), through `starci runtime deploy --from <clone>` (one verified revision change; never a hand fast-forward of the live checkout followed by a hand restart). Nothing a lane found is left listed as later: it is fixed in that round or handed to another lane opened then. A verified fix ships as its own release wave, the next alpha.N.M, as soon as it is green (owner ruling release-waves)."
88
88
  debug-replay-before-done: "A fix for a defect found on a live host is done when the runtime change that handles it (the contract, prompt, policy row, gate, guard or code) and its edge-case entry (found: live, the rule that now handles it) land on one commit and starci runtime verify is green on that commit. The runtime has no specs of its own (owner ruling no-runtime-specs): no runtime spec or replay spec is written; an entry may name a package spec or the scaffold e2e that already covers it."
89
89
  debug-channels: "Debug speaks only through runtime channels (starci supervisor tell; a durable ruling to a Kernel through the Supervisor's notify), never by typing into a seat's terminal, and never to an Op or a Critic."
90
90
 
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "4ab7076e241f09f78dcd0009085eb283a16132f623b508015e0ef805f2f6845b",
4
+ "hash": "3ac967b6f4bb2136cbc7b29a8da5d28994140c84c3543ef0748a379e4667c443",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -789,6 +789,58 @@ export const CATALOG = {
789
789
  ],
790
790
  "runtimeSide": "host"
791
791
  },
792
+ "codemod": {
793
+ "summary": "migrate front-end button variants to declared intent and report cases requiring a human decision",
794
+ "owner": "@starci/hfs",
795
+ "positional": [
796
+ {
797
+ "name": "migration",
798
+ "enum": [
799
+ "button-intent"
800
+ ],
801
+ "required": true,
802
+ "variadic": false
803
+ }
804
+ ],
805
+ "flags": [
806
+ {
807
+ "name": "write",
808
+ "type": "boolean",
809
+ "summary": "apply safe source edits; otherwise preview without changing source"
810
+ },
811
+ {
812
+ "name": "format",
813
+ "type": "enum",
814
+ "enum": [
815
+ "json",
816
+ "markdown"
817
+ ],
818
+ "default": "markdown",
819
+ "summary": "migration report format"
820
+ },
821
+ {
822
+ "name": "report",
823
+ "type": "string",
824
+ "summary": "also save the complete report to a .json or .md file"
825
+ }
826
+ ],
827
+ "exit": {
828
+ "0": "migration has no unresolved cases",
829
+ "1": "unresolved cases require a human decision",
830
+ "2": "refusal or bad usage"
831
+ },
832
+ "json": "flag",
833
+ "examples": [
834
+ "starci app codemod button-intent",
835
+ "starci app codemod button-intent --write --format json --report button-intent.json"
836
+ ],
837
+ "editions": [
838
+ "full",
839
+ "lite"
840
+ ],
841
+ "impl": null,
842
+ "runtimeSide": "host"
843
+ },
792
844
  "coverage": {
793
845
  "summary": "merge the sharded unit coverage into one lcov report, or prove that report at 100 per file",
794
846
  "owner": "@starci/hfs",
@@ -8617,13 +8669,14 @@ export const CATALOG = {
8617
8669
  ],
8618
8670
  "exit": {
8619
8671
  "0": "the candidate CI of the release sha was green, then main plus its tag were pushed atomically, or --plan found nothing missing",
8620
- "1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, suite red, main moved, secret scan) and nothing was pushed",
8672
+ "1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, RELEASE_VERSION_NOT_NEXT, suite red, main moved, secret scan) and nothing was pushed",
8621
8673
  "2": "bad usage"
8622
8674
  },
8623
8675
  "json": "flag",
8624
8676
  "examples": [
8625
8677
  "starci release cut",
8626
8678
  "starci release cut --plan --tag v1.0.0-alpha.9",
8679
+ "starci release cut --tag v1.0.0-alpha.31.1",
8627
8680
  "starci release cut --tag v1.0.0-alpha.9 --rows \"npm run test:e2e,linux-parity\"",
8628
8681
  "starci release cut --repo <path> --tag v1.0.0-alpha.9 --json"
8629
8682
  ],
@@ -8634,6 +8687,9 @@ export const CATALOG = {
8634
8687
  "script": "scripts/supervisor/release-cut-cli.mjs"
8635
8688
  },
8636
8689
  "conventions": [
8690
+ "Naming (owner ruling release-waves): the next release is alpha.N.M+1 of the highest v* tag (a wave or a fix) or the next line alpha.N+1,",
8691
+ "derived from the v* tags, never guessed (RELEASE_VERSION_NOT_NEXT); without --tag the refusal names both.",
8692
+ "The legacy -fix form is read and never cut.",
8637
8693
  "OWNER RULING candidate-ci (2026-10-18): never again have main receive a release whose CI is red, with the owner confirming afterwards.",
8638
8694
  "Before the suite, including --plan, the workflows the candidate CI waits for (release-cut.yaml candidate.workflows) must be readable,",
8639
8695
  "else RELEASE_CI_UNKNOWN. The latest run on main is no longer read: a red main needs no owner override before the next release.",
@@ -8884,6 +8940,7 @@ export const CATALOG = {
8884
8940
  "runtime-package refuses changed inputs or registry state after its cold proof and never performs rebind or example writes",
8885
8941
  "runtime-package plans the upload channel and release-declared npm dist-tags, repairs stale tags without republishing, and reads every target back",
8886
8942
  "runtime-package refuses an older release on its line and preserves tags pointing to newer releases on other lines",
8943
+ "every alpha.N and wave alpha.N.M share the one alpha line, so a wave never moves a dist-tag behind a later wave or line",
8887
8944
  "publication never commits, pushes or creates Git tags; release cut owns those effects"
8888
8945
  ],
8889
8946
  "runtimeSide": "tree"
@@ -14289,6 +14346,44 @@ export const CATALOG = {
14289
14346
  },
14290
14347
  "runtimeSide": "host"
14291
14348
  },
14349
+ "export-repaints": {
14350
+ "summary": "export strict Grammar vendor findings as CSS repair assignments",
14351
+ "owner": "runtime",
14352
+ "flags": [
14353
+ {
14354
+ "name": "out-dir",
14355
+ "type": "string",
14356
+ "required": true,
14357
+ "summary": "directory for the repaint exports"
14358
+ },
14359
+ {
14360
+ "name": "root",
14361
+ "type": "string",
14362
+ "summary": "runtime root whose Grammar sources and vendor reference are judged"
14363
+ },
14364
+ {
14365
+ "name": "check",
14366
+ "type": "boolean",
14367
+ "summary": "report export drift without writing"
14368
+ }
14369
+ ],
14370
+ "exit": {
14371
+ "0": "exports written or verified",
14372
+ "1": "export drift or unreadable judge inputs",
14373
+ "2": "bad usage"
14374
+ },
14375
+ "json": "none",
14376
+ "examples": [
14377
+ "starci work export-repaints --out-dir <directory>"
14378
+ ],
14379
+ "editions": [
14380
+ "full"
14381
+ ],
14382
+ "impl": {
14383
+ "script": "scripts/work/ui/export-repaints.mjs"
14384
+ },
14385
+ "runtimeSide": "host"
14386
+ },
14292
14387
  "grammar-geometry": {
14293
14388
  "summary": "emit grammar geometry guidance or check captured geometry",
14294
14389
  "owner": "runtime",
@@ -14345,7 +14440,22 @@ export const CATALOG = {
14345
14440
  {
14346
14441
  "name": "write",
14347
14442
  "type": "boolean",
14348
- "summary": "rewrite the grammar DNA from the package; without it only compares"
14443
+ "summary": "rewrite selected generated output; mutually exclusive with --check"
14444
+ },
14445
+ {
14446
+ "name": "check",
14447
+ "type": "boolean",
14448
+ "summary": "compare selected generated output without writing (the default); mutually exclusive with --write"
14449
+ },
14450
+ {
14451
+ "name": "intent-only",
14452
+ "type": "boolean",
14453
+ "summary": "scope generated output to DNA intent mapping blocks; may combine with --catalog-only"
14454
+ },
14455
+ {
14456
+ "name": "catalog-only",
14457
+ "type": "boolean",
14458
+ "summary": "scope generated output to the Button and IconButton intent props catalog; may combine with --intent-only"
14349
14459
  }
14350
14460
  ],
14351
14461
  "exit": {
@@ -2,6 +2,8 @@
2
2
  // pre-push hook (scripts/guards/release-push-gate.mjs) and the release cut (scripts/supervisor/release-cut.mjs). The remote
3
3
  // main of the runtime moves exactly when a release is cut, so a push of main or of a v* tag is judged against this list:
4
4
  // RELEASE_VERSION_UNCHANGED package.json `version` at the pushed commit differs from the version at the remote main's head
5
+ // RELEASE_VERSION_NOT_NEXT that version is the next release after the highest v* tag (scripts/lib/release-version.mjs, owner ruling release-waves):
6
+ // the next wave alpha.N.M+1 of its line or the next line alpha.N+1, never the legacy -fix form
5
7
  // RELEASE_TAG_VERSION the release tag is v<version> of the pushed commit
6
8
  // RELEASE_CHANGELOG_SECTION CHANGELOG.md holds `## [<version>] - <date>` (a dated heading) with no unfinished marker (R222)
7
9
  // RELEASE_TAG_MISSING an ANNOTATED tag v<version> points at the pushed commit
@@ -17,6 +19,7 @@ import { tag as gitTag } from '../api/git/tag.mjs';
17
19
  import { mergeBaseQuery } from '../api/git/merge-base-query.mjs';
18
20
  import { pathKey, realPath } from '../lib/path-key.mjs';
19
21
  import { changelogSection, releaseNotesFindings } from '../hfs/runtime-rules/release-notes.mjs';
22
+ import { nextReleases, releaseVersionRefusal, taggedVersions } from '../lib/release-version.mjs';
20
23
  import { readL4Record } from './release-record.mjs';
21
24
  import { suiteModeOf } from './release-suite-mode.mjs';
22
25
 
@@ -122,14 +125,27 @@ function releaseAncestor(repo, before, after) {
122
125
  return null;
123
126
  }
124
127
 
125
- function versionFindings({ cwd, commit, remoteCommit, version }) {
128
+ /** The next releases named from the released versions, for a fix text: "v<wave> or v<line>". */
129
+ const nextNamed = (released) => {
130
+ const next = nextReleases(released);
131
+ return next ? `v${next.wave} or v${next.line}` : 'v<new version>';
132
+ };
133
+
134
+ function versionFindings({ cwd, remoteCommit, version, released }) {
126
135
  if (!remoteCommit) return [];
127
136
  const remoteVersion = versionAt(cwd, remoteCommit);
128
137
  if (remoteVersion === null) return [finding('RELEASE_REMOTE_HEAD_UNKNOWN', `the remote main head ${remoteCommit.slice(0, 9)} is not in this repository, so its version cannot be compared`, 'git fetch origin main')];
129
- if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `bump package.json version, move the CHANGELOG section to the new version, commit, then ${CUT_COMMAND('v<new version>')}`)];
138
+ if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released))}`)];
130
139
  return [];
131
140
  }
132
141
 
142
+ /** The version must be the next release after the highest v* tag other than its own: a wave alpha.N.M is counted, never guessed. */
143
+ function nextReleaseFindings({ version, released }) {
144
+ if (released === null) return [finding('RELEASE_VERSION_NOT_NEXT', 'the v* tags are unreadable, so the next release cannot be derived', 'git fetch --tags')];
145
+ const refusal = releaseVersionRefusal(version, released);
146
+ return refusal ? [finding('RELEASE_VERSION_NOT_NEXT', refusal, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released.filter((entry) => entry !== version)))}`)] : [];
147
+ }
148
+
133
149
  function changelogFindings({ cwd, commit, version, tag }) {
134
150
  const changelog = gitText(show, [`${commit}:CHANGELOG.md`], cwd);
135
151
  if (changelog === null) return [finding('RELEASE_CHANGELOG_SECTION', 'CHANGELOG.md is not readable at the pushed commit', 'restore CHANGELOG.md')];
@@ -176,8 +192,11 @@ export function releaseFindings({ cwd, commit, remoteCommit = null, tag = null,
176
192
  const expected = `v${version}`;
177
193
  const named = tag ?? expected;
178
194
  if (named !== expected) return [finding('RELEASE_TAG_VERSION', `the tag ${named} is not v${version}, the version of ${commit.slice(0, 9)}`, `tag v${version}, or set package.json version to ${named.replace(/^v/, '')}`)];
195
+ const listed = gitText(gitTag, ['--list', 'v*'], cwd);
196
+ const released = listed === null ? null : taggedVersions(listed);
179
197
  return [
180
- ...versionFindings({ cwd, commit, remoteCommit, version }),
198
+ ...versionFindings({ cwd, remoteCommit, version, released: released ?? [] }),
199
+ ...nextReleaseFindings({ version, released }),
181
200
  ...changelogFindings({ cwd, commit, version, tag: named }),
182
201
  ...(needTag ? tagFindings({ cwd, commit, tag: named }) : []),
183
202
  ...(needReceipt ? receiptFindings({ cwd, commit, tag: named }) : []),
@@ -218,3 +218,21 @@ export function utilityFamilies(utility) {
218
218
  export function ruleFamilies(rule) {
219
219
  return [...new Set([...rule.apply.flatMap(utilityFamilies), ...rule.declarations.map((entry) => propertyFamily(entry.property)).filter(Boolean)])].sort(byCodeUnit);
220
220
  }
221
+
222
+ // Container composition is layout even when the vendor sets a default for it. Keep this separate from
223
+ // ruleFamilies: the vendor census records every declaration, while the strict judge measures paint.
224
+ const LAYOUT_FAMILIES = new Set([
225
+ 'gap', 'inline-size', 'display', 'order',
226
+ 'flex', 'flex-basis', 'flex-direction', 'flex-flow', 'flex-grow', 'flex-shrink', 'flex-wrap',
227
+ 'align-content', 'align-items', 'align-self',
228
+ 'justify-content', 'justify-items', 'justify-self',
229
+ 'place-content', 'place-items', 'place-self',
230
+ 'grid', 'grid-template', 'grid-template-areas', 'grid-template-columns', 'grid-template-rows',
231
+ 'grid-auto-columns', 'grid-auto-flow', 'grid-auto-rows', 'grid-area',
232
+ 'grid-column', 'grid-column-start', 'grid-column-end', 'grid-row', 'grid-row-start', 'grid-row-end',
233
+ ]);
234
+
235
+ /** The paint families a strict Grammar may only change through a declared vendor override. */
236
+ export function repaintFamilies(rule) {
237
+ return ruleFamilies(rule).filter((family) => !LAYOUT_FAMILIES.has(family));
238
+ }
@@ -1,13 +1,6 @@
1
- // grammar-vendor-source.mjs - pure JSX and expression readers for Grammar vendor-parity judgments.
2
- // The caller supplies source text, import aliases and measured role declarations; this module performs no I/O.
3
- // Kept separate from the parity rules so parsing and vendor render-prop custody have one owner.
4
-
1
+ // JSX and static role-expression readers shared by the Grammar vendor judge.
5
2
  const lineOf = (text, index) => text.slice(0, index).split('\n').length;
6
3
 
7
- // ---------------------------------------------------------------------------
8
- // Reading JSX without a parser: the opening tags of the aliases a file imports from @heroui/react.
9
- // ---------------------------------------------------------------------------
10
-
11
4
  /** The index after the quoted string that starts at `start`. */
12
5
  function skipQuoted(text, start) {
13
6
  const quote = text[start];
@@ -84,14 +77,23 @@ function readAttributes(text, from) {
84
77
 
85
78
  /** The opening tags of the given import aliases: `{alias, part, attrs, line}` (`part` is `Root` for the bare alias). */
86
79
  export function heroTags(text, aliases) {
87
- if (!aliases.length) return [];
88
- const pattern = new RegExp(String.raw`<(${aliases.join('|')})(?:\.(\w+))?(?=[\s/>])`, 'g');
89
- return [...text.matchAll(pattern)].map((match) => ({
90
- alias: match[1],
91
- part: match[2] ?? 'Root',
92
- attrs: readAttributes(text, match.index + match[0].length),
93
- line: lineOf(text, match.index),
94
- }));
80
+ const known = new Set(aliases);
81
+ return jsxTags(text).filter((tag) => known.has(tag.alias)).map(({ index, ...tag }) => ({ ...tag, line: lineOf(text, index) }));
82
+ }
83
+
84
+ /** The string literals of an expression, without their quotes. */
85
+ export const stringLiterals = (raw) => (String(raw).match(/"[^"\n]*"|'[^'\n]*'|`[^`$]*`/g) ?? []).map((literal) => literal.slice(1, -1));
86
+
87
+ /** The class tokens an element carries: the literals of its className, with a bare constant resolved one level in the file. */
88
+ export function classTokens(text, attrs) {
89
+ const raw = attrs.find((attr) => attr.name === 'className')?.raw;
90
+ if (raw === undefined) return [];
91
+ const names = String(raw).match(/\b[A-Za-z_]\w*\b/g) ?? [];
92
+ const resolved = names.flatMap((name) => {
93
+ const declaration = new RegExp(String.raw`\bconst\s+${name}\s*=\s*([^\n]*(?:\n[^\n;]*)?)`).exec(text);
94
+ return declaration ? stringLiterals(declaration[1]) : [];
95
+ });
96
+ return [...stringLiterals(raw), ...resolved].flatMap((value) => value.split(/\s+/)).filter(Boolean);
95
97
  }
96
98
 
97
99
  /** The vendor imports of a source: alias -> vendor component name. */
@@ -143,7 +145,7 @@ function jsxOpening(text, index) {
143
145
  }
144
146
 
145
147
  /** Opening JSX tags, excluding JavaScript strings and comments; attribute expressions remain intact. */
146
- export function jsxTags(text) {
148
+ function jsxTags(text) {
147
149
  const tags = [];
148
150
  let index = 0;
149
151
  while (index < text.length) {
@@ -291,7 +293,7 @@ function memberExpressionStrings(text, member, seen) {
291
293
  }
292
294
 
293
295
  /** Result literals of a JSX expression, including local constants; condition literals are not result values. */
294
- export function expressionStrings(text, raw, seen = new Set()) {
296
+ function expressionStrings(text, raw, seen = new Set()) {
295
297
  raw = raw.trim();
296
298
  if (enclosesExpression(raw)) return expressionStrings(text, raw.slice(1, -1), seen);
297
299
  if (/^(?:"[^"\n]*"|'[^'\n]*'|`[^`$]*`)$/.test(raw)) return [raw.slice(1, -1)];
@@ -339,7 +341,20 @@ function vendorRenderRoot(text, callback, declarations) {
339
341
  }
340
342
 
341
343
  /** The local render callbacks supplied to vendor roots keep the vendor's state, semantics and prop custody. */
342
- export function vendorRenderRoots(text, aliases, declarations) {
344
+ function vendorRenderRoots(text, aliases, declarations) {
343
345
  const callbacks = heroTags(text, [...aliases.keys()]).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'render').map((attr) => ({ raw: attr.raw.trim(), offset: attr.offset, owner: aliases.get(tag.alias) })));
344
346
  return callbacks.flatMap((callback) => vendorRenderRoot(text, callback, declarations));
345
347
  }
348
+
349
+ /** The JSX roles a file writes by hand that the measured vendor references expose. */
350
+ export function roleFindings({ file, text, allowed, vendorRoles, roleDeclarations, finding }) {
351
+ const vendorAliases = importAliases(text);
352
+ const vendorRenders = vendorRenderRoots(text, vendorAliases, roleDeclarations);
353
+ const vendorOwnsRole = (tag, role) => {
354
+ const owner = vendorAliases.get(tag.alias);
355
+ const declaration = roleDeclarations[role];
356
+ return owner !== undefined && (declaration?.contracts.includes(`${owner}.${tag.part}`) || (tag.part === 'Root' && declaration?.publicApi === owner));
357
+ };
358
+ const roles = jsxTags(text).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'role').flatMap((attr) => expressionStrings(text, attr.raw)).filter((role) => !vendorOwnsRole(tag, role) && !vendorRenders.some((root) => tag.index === root.index && root.roles.includes(role)))).filter((role) => vendorRoles.has(role));
359
+ return [...new Set(roles)].filter((role) => !allowed('role', `${file}:${role}`)).map((role) => finding('role', file, 0, `hand-rolls role="${role}", which HeroUI has a component for: use it (override target role:${file}:${role})`));
360
+ }