@manohub/app-kit 0.2.0 → 0.2.3
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.
- package/CONTRACT.md +174 -9
- package/README.md +9 -5
- package/bin/appkit.mjs +121 -0
- package/lint/__tests__/guardrails.spec.mjs +154 -0
- package/lint/component-audit.mjs +1 -1
- package/lint/guardrails.config.schema.json +42 -0
- package/lint/pre-commit.sample +38 -26
- package/lint/run-all.mjs +63 -59
- package/lint/shared.mjs +462 -347
- package/lint/structure-audit.mjs +1 -1
- package/lint/style-audit.mjs +1 -1
- package/package.json +7 -3
- package/skills/README.md +32 -10
- package/skills/app-kit/SKILL.md +19 -11
- package/skills/app-kit/references/adoption.md +76 -6
- package/skills/app-kit/references/contract-index.md +7 -2
- package/skills/app-kit-dev/SKILL.md +40 -8
- package/skills/app-kit-dev/references/page-recipes.md +53 -5
- package/skills/app-kit-dev/references/style-rules.md +2 -2
- package/skills/app-kit-migrate/SKILL.md +30 -11
- package/skills/app-kit-migrate/references/migration-playbook.md +53 -4
- package/skills/install.mjs +298 -203
package/CONTRACT.md
CHANGED
|
@@ -102,7 +102,7 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
|
|
|
102
102
|
<AppPanel
|
|
103
103
|
title="值映射列表"
|
|
104
104
|
toolbar={<><AppSearchBox … /><AppSelect … /></>}
|
|
105
|
-
actions={<><AppButton tone="primary">新建</AppButton><AppButton>刷新</AppButton></>}>
|
|
105
|
+
actions={<><AppButton tone="primary">新建</AppButton><AppButton tone="secondary">刷新</AppButton></>}>
|
|
106
106
|
<AppTable framed rows={rows} columns={cols} rowKey="id" />
|
|
107
107
|
|
|
108
108
|
{/* 分页跟**承载表格的容器**走:表格在本面板内 → AppPanel.Footer(不要提到 AppShell.Footer) */}
|
|
@@ -447,6 +447,112 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
|
|
|
447
447
|
右上角 ✕ / Esc 只关窗**不触发回调**(farris 行为),Promise 不落定 —— 调用方按"未确认"处理即可。
|
|
448
448
|
- 轻量反馈(保存成功/失败)用 `notify`,长任务用 `loading`;两者都不带确认语义。
|
|
449
449
|
|
|
450
|
+
### 4.14 页签(`AppTabs`)
|
|
451
|
+
|
|
452
|
+
```tsx
|
|
453
|
+
<AppTabs modelValue={activeTab}
|
|
454
|
+
items={[{ key: 'overview', label: '概览' }, { key: 'tools', label: '工具' }]}
|
|
455
|
+
v-slots={{ overview: () => <Overview />, tools: () => <Tools /> }}
|
|
456
|
+
onChange={(key) => (activeTab = key)} />
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- `items` 是唯一的页签定义入口:`{ key, label }`(`key` 同时是**内容插槽名**)。
|
|
460
|
+
- `modelValue` + `onChange(key)` 走上层统一词表;**点击只上报、消费方必须回写 `modelValue`**(受控语义,不回写界面不动)。
|
|
461
|
+
- `fill` 缺省 `true`(撑满容器高度,抽屉/面板内需要);不需要撑满时显式 `fill={false}`。
|
|
462
|
+
- 页签条本身的视觉与滚动归组件;页面里不要自绘标签行,也不要用 `AppLayout` 拼一个「像页签」的按钮组。
|
|
463
|
+
|
|
464
|
+
### 4.15 页面级筛选(`AppFilter` / `appFilterSelect` / `appFilterInput`)
|
|
465
|
+
|
|
466
|
+
**位置**:只出现在 `AppShell.Filter`(§4.5:字段 > 3、跨区域、或已是组合查询方案)。区域级 ≤3 字段的筛选仍在
|
|
467
|
+
`AppPanel.toolbar`,用普通表单控件。
|
|
468
|
+
|
|
469
|
+
```tsx
|
|
470
|
+
const fields: AppFilterField[] = [
|
|
471
|
+
{ id: 'field-repoId', code: 'repoId', name: '所属仓库', editor: appFilterSelect(repoOptions) },
|
|
472
|
+
{ id: 'field-state', code: 'state', name: '状态', editor: appFilterSelect(stateOptions) },
|
|
473
|
+
{ id: 'field-owner', code: 'owner', name: '负责人', editor: appFilterInput('输入负责人') },
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
<AppShell.Filter>
|
|
477
|
+
<AppFilter fields={fields}
|
|
478
|
+
defaults={{ 'field-repoId': repoId, 'field-state': 'all' }}
|
|
479
|
+
searchFields="name" searchPlaceholder={t('filter.keywordPlaceholder')}
|
|
480
|
+
onChange={(values, meta) => applyFilter(values)}
|
|
481
|
+
onQuery={(values, meta) => applyFilter({ ...values, force: true })} />
|
|
482
|
+
</AppShell.Filter>
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- `fields` 必填,元素是 `{ id, code, labelCode?, name, controlType?, visible?, editor? }`。
|
|
486
|
+
`id` 是**字段标识**(默认值按它索引),`code` 是**业务条件名**(事件值与后端参数按它走);两者不要混用。
|
|
487
|
+
- **编辑器一律用两个工厂生成**,不要手写 farris 编辑器配置:
|
|
488
|
+
- `appFilterSelect([{ label, value }])` —— 下拉,应用侧只给标准选项;
|
|
489
|
+
- `appFilterInput(placeholder)` —— 单行文本(内部固定实时回传)。
|
|
490
|
+
工厂之外的 farris 字段(`valueField` / `enumValueType` / `combo-list` …)属于包内实现,应用侧写不出来。
|
|
491
|
+
- `defaults` 用**字段 id → 业务值**;哨兵 `'all'` 表示「全部」,事件里会被翻译成空串(后端不传该条件即不过滤)。
|
|
492
|
+
- 事件:`onChange(values, meta)` 条件变化(值已归一化为 `code → 字符串`)、`onQuery(values, meta)` 查询按钮/回车
|
|
493
|
+
(`meta.query === true`)、`onReady()` 条件区就绪。**默认值注入时机、payload 三形态归一、就绪轮询都由组件吃掉**,
|
|
494
|
+
应用侧不要再写 `solutionRef` 直调或 `setTimeout` 补时序。
|
|
495
|
+
- `ignoreInitialEmpty`:按 key 重建条件区时挂载即触发一次空条件查询,传 `true` 忽略该次。
|
|
496
|
+
- `keywordCodes` 决定哪些 code 算「关键字字段」(缺省 `fuzzysearch` / `keyword` / `name` / `displayname`)。
|
|
497
|
+
|
|
498
|
+
### 4.16 树(`AppTree`)
|
|
499
|
+
|
|
500
|
+
`AppTree` 为**自建件**(不封装底层树组件,理由见 §9.6),受控展开、三态内建。
|
|
501
|
+
|
|
502
|
+
```tsx
|
|
503
|
+
<AppTree nodes={nodes} rowKey="id" labelKey="name"
|
|
504
|
+
selected={selectedId} onSelect={(node) => (selectedId = node.id)}
|
|
505
|
+
expandedKeys={expandedKeys} onExpandChange={(keys) => (expandedKeys = keys)}
|
|
506
|
+
loading={loading} error={error} empty="暂无业务域"
|
|
507
|
+
renderNode={(node) => <AppBadge tone="info">{countOf(node.id)}</AppBadge>} />
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
| prop | 说明 |
|
|
511
|
+
|---|---|
|
|
512
|
+
| `nodes` / `rowKey` | **都必填**;`rowKey` 是字段名或 `(node) => key` |
|
|
513
|
+
| `labelKey` / `childrenKey` | 标签字段名(默认 `label`)/ 子节点字段名(默认 `children`) |
|
|
514
|
+
| `selected` + `onSelect(node)` | 选中是**行主键值**,回调给的是**节点对象**(不是 key),要 key 自己取 |
|
|
515
|
+
| `expandedKeys` / `defaultExpandedKeys` / `onExpandChange(keys)` | 传了 `expandedKeys` 即完全受控;不传则内部自持,`defaultExpandedKeys` 给初值 |
|
|
516
|
+
| `renderNode(node)` | 节点附加内容(计数徽标等),渲染在标签之后 |
|
|
517
|
+
| `size` | `md`(行高 30,默认)/ `sm`(紧凑 28) |
|
|
518
|
+
| `loading` / `error` / `empty` / `emptyActionText` + `onEmptyAction` / `errorTitle` / `errorActionText` + `onErrorAction` | 三态与动作,口径同 §4.6(**错误不要塞进 `empty`**) |
|
|
519
|
+
| `treeKey` | 强制重挂逃生舱:正常数据刷新**不需要**它(展开态不会丢) |
|
|
520
|
+
|
|
521
|
+
- **交互分工**:点箭头展开/收拢、**点行选中**(`onSelect`)—— 不要给行挂自绘点击展开逻辑。
|
|
522
|
+
- **缩进步进由组件算**(`data-level` + CSS 变量),应用侧不要改行内 padding 或写死缩进像素(§9.6)。
|
|
523
|
+
- 受控展开是「数据刷新不丢展开态」的实现前提:不要用「数据变了就换 `key` 重挂」的写法绕过。
|
|
524
|
+
|
|
525
|
+
### 4.17 分页(`AppPagination` / `useClientPagination`)
|
|
526
|
+
|
|
527
|
+
```tsx
|
|
528
|
+
// 服务端分页:页面持有 page / pageSize
|
|
529
|
+
<AppPagination page={page} pageSize={pageSize} total={total}
|
|
530
|
+
onPageChange={(p) => (page = p)}
|
|
531
|
+
onPageSizeChange={(s) => { pageSize = s; page = 0 }} />
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
- `page` / `pageSize` / `total` **必填**;`page` 是 **0 基**(与后端分页参数一致,1 基换算由组件内做)。
|
|
535
|
+
- 事件是 **`onPageChange(page)` 与 `onPageSizeChange(pageSize)`** —— 没有 `onChange`;切页大小后回第几页属消费方语义
|
|
536
|
+
(现状惯例:回第 0 页)。
|
|
537
|
+
- `pageSizeOptions`(缺省 `[10, 20, 50]`)、`showInfo`(缺省 `true`,总条数信息自带,**不要自绘「共 N 条」**)。
|
|
538
|
+
- **位置**跟承载表格的容器走(§4.4):表格在 `AppPanel` 内 → `AppPanel.Footer`;表格直接挂 `AppShell.Body` 下 →
|
|
539
|
+
`AppShell.Footer`;无数据 / 加载失败时不渲染分页。
|
|
540
|
+
|
|
541
|
+
**客户端分页**(数据整批在前端)用 `useClientPagination`,不要自己写 `slice` + 越界处理:
|
|
542
|
+
|
|
543
|
+
```tsx
|
|
544
|
+
const list = useClientPagination(() => filteredRows.value)
|
|
545
|
+
|
|
546
|
+
<AppTable framed rows={list.pageRows} columns={cols} rowKey="id" />
|
|
547
|
+
<AppPagination page={list.page} pageSize={list.pageSize} total={list.total}
|
|
548
|
+
pageSizeOptions={list.pageSizeOptions}
|
|
549
|
+
onPageChange={list.setPage} onPageSizeChange={list.setPageSize} />
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
- 返回 `page` / `pageSize` / `total` / `pageCount` / `pageRows`(已切片,直接喂 `AppTable.rows`)/ `pageSizeOptions` /
|
|
553
|
+
`setPage` / `setPageSize`;页码同样是 0 基。
|
|
554
|
+
- 数据变少(过滤 / 删除)导致当前页越界时**自动回退到最后一页**,不会出现「空白页但分页器显示有数据」。
|
|
555
|
+
|
|
450
556
|
---
|
|
451
557
|
|
|
452
558
|
## 5. 反例库(这些写法一律违规)
|
|
@@ -500,10 +606,23 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
|
|
|
500
606
|
|
|
501
607
|
需要的能力本包没有时,**不要**在页面里自绘外观,也不要直接引底层组件库:
|
|
502
608
|
|
|
503
|
-
1.
|
|
504
|
-
2.
|
|
609
|
+
1. 先查本文件已列能力(含 §7.1 缺件清单)与包导出面(`@manohub/app-kit` 的导出列表);
|
|
610
|
+
2. 仍缺 → 走**包维护侧建件**:在包内新建封装件(命名锚定通用规范名,props 查 §3 词表),补导出面与单测。
|
|
611
|
+
消费方**不要**在自己仓里仿制一个近似件(仿制品一建就与骨架层两套口径,后续还得拆);
|
|
505
612
|
3. 建件前先确认底层库是否已有对应件:有则封装(吃掉其坑),无则自建(原生元素 + `--ui-*` 令牌);
|
|
506
|
-
4.
|
|
613
|
+
4. 页面侧只用建好的件;缺件期间按 §7.1 给的替代口径顶住,不要改护栏放过违规。
|
|
614
|
+
|
|
615
|
+
### 7.1 当前缺件清单(判断「是否漏改 / 能不能实现」时看这里)
|
|
616
|
+
|
|
617
|
+
| 能力 | 现状 | 缺件期间的替代口径 |
|
|
618
|
+
|---|---|---|
|
|
619
|
+
| 日期 / 日期时间选择 | **无**(无 `AppDatePicker`) | 暂用 `AppInput` 并标明格式;需真日期控件走 §7 建件 |
|
|
620
|
+
| 数字 / 步进输入 | **无**(无 `AppNumber`) | 暂用 `AppInput` + 提交侧校验;需步进控件走 §7 建件 |
|
|
621
|
+
| 加载骨架屏 | **无**(`AppSkeleton` 属后续批次) | 用 §4.6 三态的 `loading`(表格/树内建,块级用 `AppQueryState`) |
|
|
622
|
+
| 抽屉(Drawer) | **无** | 宽内容用 `AppDialog`(定高可滚)或页面级双栏 `AppShell.Split` |
|
|
623
|
+
| 描述列表(Descriptions) | **不再需要** | 摘要 / 详情走 §4.7 的 `AppForm.Item text={…}` 只读文本行 |
|
|
624
|
+
|
|
625
|
+
> 本清单随版本变动;**不存在的能力一律不要在应用侧自绘**,也不要用别的件"拼一个像的"当替代品长期留着。
|
|
507
626
|
|
|
508
627
|
---
|
|
509
628
|
|
|
@@ -513,20 +632,66 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
|
|
|
513
632
|
`package.json` 里**只挂两条**(三条护栏由统一入口并发跑,不要各挂一条):
|
|
514
633
|
|
|
515
634
|
```json
|
|
516
|
-
"lint": "
|
|
517
|
-
"lint:changed": "
|
|
635
|
+
"lint": "appkit lint",
|
|
636
|
+
"lint:changed": "appkit lint --changed",
|
|
637
|
+
"lint:strict": "appkit lint --strict"
|
|
518
638
|
```
|
|
519
639
|
|
|
640
|
+
(scripts 里可以省略 `pnpm exec`:npm/pnpm 会把 `node_modules/.bin` 加进 PATH。终端手动跑时写
|
|
641
|
+
`pnpm exec appkit lint`,npm 消费方写 `npx appkit lint`。)
|
|
642
|
+
|
|
520
643
|
- 统一入口并发跑三条:`style-audit.mjs`(样式:只允许布局属性 / 禁 `!important` / 禁自写 rem)、
|
|
521
644
|
`component-audit.mjs`(组件使用:禁底层直连 / 禁底层风格写法)、
|
|
522
645
|
`structure-audit.mjs`(页面结构:骨架必用 / 禁自绘页头面板头 / 字段数上限)。
|
|
523
|
-
**排查单条**时才直接跑它(`
|
|
646
|
+
**排查单条**时才直接跑它(`pnpm exec appkit lint:style`),不必挂成脚本。
|
|
524
647
|
- `--changed` 只跑改动文件(pre-commit 用;非 git 环境不裁剪,宁可多查)。
|
|
525
648
|
- 每条违规都会给出 `file:line` + 片段 + **唯一改法**(`correction.summary` / `.example`)与 **`doc` 锚点**
|
|
526
649
|
(指回本文件对应章节)。**照 `correction` 改,不要自己另想一套。**
|
|
527
650
|
- 存量文件的口径:先产基线 → 挂起(`pending` + 豁免登记)→ **新增与改动过的文件必须归零**。
|
|
528
651
|
|
|
529
|
-
### 8.1
|
|
652
|
+
### 8.1 分阶段迁移:规则级豁免(`waivedRules`)
|
|
653
|
+
|
|
654
|
+
只做**一部分**迁移是常态(最典型的是「**只迁骨架**」:先把页面结构换成 `AppShell` / `AppPanel`,组件与样式
|
|
655
|
+
下一批次再收)。这一层用 `apps[].waivedRules` 表达:**已迁到的那一层即刻纳入门禁,没迁的登记豁免**。
|
|
656
|
+
|
|
657
|
+
```jsonc
|
|
658
|
+
// appkit-guardrails.config.json —— Shell-only 应用
|
|
659
|
+
{
|
|
660
|
+
"apps": [{
|
|
661
|
+
"dir": ".", "name": "my-app", "prefixes": ["my-"], "pending": false,
|
|
662
|
+
"waivedRules": [
|
|
663
|
+
{ "rule": "api/*", "reason": "只迁骨架(Shell-only):组件替换在下一批次", "since": "2026-09-18" },
|
|
664
|
+
{ "rule": "style/*", "reason": "同上:样式收口等骨架稳定后再做", "since": "2026-09-18" }
|
|
665
|
+
]
|
|
666
|
+
}]
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
- 规则名写 `域/规则`(如 `api/farris-import`)或整类通配(`api/*` / `style/*` / `structure/*`);
|
|
671
|
+
**每条必须写 `reason`**(配置校验会拦),缺理由的豁免等于给护栏装静音键。
|
|
672
|
+
- 豁免项**照常出现在报告里**(标成 `error/waived`),只是不计入退出码:
|
|
673
|
+
```
|
|
674
|
+
component-audit(组件使用护栏):0 个 error / 0 个 warn(其中 3 条属已登记豁免,不计入退出码)
|
|
675
|
+
[error/waived] src/views/x.tsx:2 api/farris-import
|
|
676
|
+
豁免:已登记(不参与门禁;用 --strict 可验证「收口后会怎样」)
|
|
677
|
+
```
|
|
678
|
+
技术债必须可见 —— 这是「豁免」与「关掉规则」的区别。
|
|
679
|
+
- 某条豁免**当前 0 条命中**时,报告会提示「可以撤销了」:迁移往前走了就该撤,否则它会静默地一直关着门。
|
|
680
|
+
- **两条不拦断的机制**(应用级 `pending` = 整应用挂起产基线;规则级 `waivedRules` = 分阶段)都只是
|
|
681
|
+
「暂时不计入退出码」,不是「不检查」。
|
|
682
|
+
|
|
683
|
+
### 8.2 收口验收:`--strict`
|
|
684
|
+
|
|
685
|
+
```bash
|
|
686
|
+
pnpm exec appkit lint --strict
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
`--strict` 下 **`pending` 与 `waivedRules` 一律失效**,退出码反映「真实剩下多少违规」。用于两个时点:
|
|
690
|
+
|
|
691
|
+
1. **里程碑验收**:Shell-only 阶段收尾时跑一次,确认结构类确实归零(`structure/*` 本就没有豁免);
|
|
692
|
+
2. **全量收口**:所有豁免撤销、`pending` 置 `false` 后跑一次,全绿才算迁完。
|
|
693
|
+
|
|
694
|
+
### 8.3 提交前检查(可选,目标 < 3 秒)
|
|
530
695
|
|
|
531
696
|
包内提供钩子样本,放进默认 hooks 目录即可启用(**不需要改 git 配置**):
|
|
532
697
|
|
|
@@ -539,7 +704,7 @@ export APPKIT_APP_DIR=apps/sub-app
|
|
|
539
704
|
|
|
540
705
|
钩子只跑 `run-all.mjs --changed`(改动文件),所以秒级返回;`--changed` 在非 git 环境不裁剪(宁可多查)。
|
|
541
706
|
|
|
542
|
-
### 8.
|
|
707
|
+
### 8.4 包自身(`@manohub/app-kit`)的门禁
|
|
543
708
|
|
|
544
709
|
```bash
|
|
545
710
|
pnpm --filter @manohub/app-kit type-check # 包内 vue-tsc:0 错
|
package/README.md
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
- **入口**:`@manohub/app-kit/entry` → `createSubApp`
|
|
10
10
|
- **组件/服务**:`@manohub/app-kit` → `AppShell` / `AppPanel` / `AppTree` / `AppTable` / `AppButton` / `notify` …
|
|
11
11
|
- **样式**:`@manohub/app-kit/reset.css` + `@manohub/app-kit/styles.css`(应用侧 `style.css` 固定三行,见 CONTRACT.md §1)
|
|
12
|
-
-
|
|
13
|
-
|
|
12
|
+
- **命令入口**:`appkit`(`package.json` 的 `bin`,在应用包根执行)
|
|
13
|
+
- `pnpm exec appkit lint [--changed|--strict|--json]` —— 三条护栏;单条排查用 `appkit lint:style` / `lint:component` / `lint:structure`
|
|
14
|
+
- `pnpm exec appkit install [--also-claude|--dry-run]` —— 落 AI 技能到本工程
|
|
15
|
+
- npm 消费方把 `pnpm exec` 换成 `npx`;scripts 里可直接写 `appkit lint`(`.bin` 在 PATH 里)
|
|
16
|
+
- 等价写法(老脚本/钩子可用):`node node_modules/@manohub/app-kit/lint/run-all.mjs`、`node node_modules/@manohub/app-kit/skills/install.mjs`
|
|
17
|
+
- 各护栏脚本仍单独可跑(用法见 CONTRACT.md §8)
|
|
14
18
|
|
|
15
19
|
## 分发形态
|
|
16
20
|
|
|
@@ -28,9 +32,9 @@
|
|
|
28
32
|
在消费方工程根执行即可落到自己的技能目录(幂等,包升级后重跑即刷新):
|
|
29
33
|
|
|
30
34
|
```bash
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
pnpm exec appkit install # → .codebuddy/skills/
|
|
36
|
+
pnpm exec appkit install --also-claude # 同时 → .claude/skills/
|
|
37
|
+
pnpm exec appkit install --dry-run # 只预览
|
|
34
38
|
```
|
|
35
39
|
|
|
36
40
|
装了之后,代理在做接入、迁移或页面开发时会命中本包规范与流程;技能职责边界与维护约定见 `skills/README.md`。
|
package/bin/appkit.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `appkit` —— 接入方(消费方)命令行入口。
|
|
4
|
+
*
|
|
5
|
+
* 存在的理由:接入方不该被要求记住 `node node_modules/@manohub/app-kit/lint/run-all.mjs --changed`
|
|
6
|
+
* 这类路径。本命令是**薄壳**:把子命令映射到包内既有脚本,透传参数与退出码 —— 所以
|
|
7
|
+
* `appkit lint` 与「直接跑那个脚本」是同一件事,脚本仍可单独执行(已有钩子 / 脚本不用改)。
|
|
8
|
+
*
|
|
9
|
+
* 用法(在**应用包根**执行):
|
|
10
|
+
* pnpm exec appkit install # 把随包分发的 AI 技能落到本工程
|
|
11
|
+
* pnpm exec appkit install --also-claude
|
|
12
|
+
* pnpm exec appkit lint # 三条护栏(style / component / structure)
|
|
13
|
+
* pnpm exec appkit lint --changed # 只查改动文件(提交前,含未 git add 的新增)
|
|
14
|
+
* pnpm exec appkit lint --strict # 收口验收:存量挂起与规则豁免一律失效
|
|
15
|
+
* pnpm exec appkit lint:style # 单条排查:style / component / structure
|
|
16
|
+
* pnpm exec appkit help
|
|
17
|
+
*
|
|
18
|
+
* npm 消费方把 `pnpm exec` 换成 `npx`(两者都解析本地 node_modules/.bin)。
|
|
19
|
+
* package.json 的 scripts 里可以省略前缀(npm/pnpm 会把 .bin 加进 PATH):
|
|
20
|
+
* "lint": "appkit lint", "lint:changed": "appkit lint --changed"
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
// 复用安装器里已测过的「是否被直接执行」判定:pnpm 把包放在 .pnpm 下、消费方引用的是软链,
|
|
27
|
+
// 直接比较 argv[1] 与 import.meta.url 会字面不等 → CLI 静默不执行(同一个坑踩过两次不值得)。
|
|
28
|
+
import { isDirectRun } from '../skills/install.mjs'
|
|
29
|
+
|
|
30
|
+
/** 包根(本文件在 <pkg>/bin/ 下,发布物里 lint/ 与 skills/ 都是它的兄弟目录) */
|
|
31
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
32
|
+
|
|
33
|
+
/** 子命令表:命令名 → 包内脚本与说明(新增子命令只需在这里加一行) */
|
|
34
|
+
export const COMMANDS = {
|
|
35
|
+
install: {
|
|
36
|
+
script: 'skills/install.mjs',
|
|
37
|
+
summary: '把随包分发的三个 AI 技能落到本工程(--also-claude / --target <dir> / --dry-run)',
|
|
38
|
+
},
|
|
39
|
+
lint: { script: 'lint/run-all.mjs', summary: '三条护栏一起跑(--changed / --strict / --json / --app=<name> / --cwd=<dir>)' },
|
|
40
|
+
'lint:style': { script: 'lint/style-audit.mjs', summary: '只跑样式护栏(排查单条规则时用)' },
|
|
41
|
+
'lint:component': { script: 'lint/component-audit.mjs', summary: '只跑组件使用护栏' },
|
|
42
|
+
'lint:structure': { script: 'lint/structure-audit.mjs', summary: '只跑页面结构护栏' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 解析命令行:只认 `COMMANDS` 里的子命令,未知子命令**显式报错**
|
|
47
|
+
* (静默忽略会让人以为命令生效了,而护栏其实没跑)。
|
|
48
|
+
*/
|
|
49
|
+
export function parseCommand(argv = []) {
|
|
50
|
+
const [first = '', ...rest] = argv
|
|
51
|
+
if (!first || first === 'help' || first === '--help' || first === '-h') return { kind: 'help' }
|
|
52
|
+
if (first === '--version' || first === '-v') return { kind: 'version' }
|
|
53
|
+
const command = COMMANDS[first]
|
|
54
|
+
if (!command) return { kind: 'unknown', command: first }
|
|
55
|
+
return { kind: 'run', name: first, script: command.script, args: rest }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function usage(version = readVersion()) {
|
|
59
|
+
const lines = [
|
|
60
|
+
`appkit ${version} —— @manohub/app-kit 命令入口(在应用包根执行;npm 消费方用 npx 代替 pnpm exec)`,
|
|
61
|
+
'',
|
|
62
|
+
'用法:',
|
|
63
|
+
' pnpm exec appkit <命令> [参数]',
|
|
64
|
+
'',
|
|
65
|
+
'命令:',
|
|
66
|
+
]
|
|
67
|
+
for (const [name, command] of Object.entries(COMMANDS)) {
|
|
68
|
+
lines.push(` ${name.padEnd(16)}${command.summary}`)
|
|
69
|
+
}
|
|
70
|
+
lines.push(' help 显示本帮助')
|
|
71
|
+
lines.push(' --version 显示版本号')
|
|
72
|
+
lines.push('')
|
|
73
|
+
lines.push('参数会原样透传给对应脚本(如 `appkit lint --changed --strict`);退出码同样是脚本的退出码。')
|
|
74
|
+
return lines.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 读包自身版本(`appkit --version` 用) */
|
|
78
|
+
export function readVersion() {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version ?? 'unknown'
|
|
81
|
+
} catch {
|
|
82
|
+
return 'unknown'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main() {
|
|
87
|
+
const parsed = parseCommand(process.argv.slice(2))
|
|
88
|
+
|
|
89
|
+
if (parsed.kind === 'help') {
|
|
90
|
+
console.log(usage())
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (parsed.kind === 'version') {
|
|
94
|
+
console.log(readVersion())
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (parsed.kind === 'unknown') {
|
|
98
|
+
console.error(`[appkit] 未知命令:${parsed.command}\n\n${usage()}`)
|
|
99
|
+
process.exitCode = 1
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const script = join(PKG_ROOT, parsed.script)
|
|
104
|
+
if (!existsSync(script)) {
|
|
105
|
+
console.error(`[appkit] 包内缺少脚本 ${parsed.script}(安装不完整?请重新安装 @manohub/app-kit)`)
|
|
106
|
+
process.exitCode = 1
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// stdio 继承:护栏是给人看的(含彩色/实时输出),不要在这里再包一层缓冲
|
|
111
|
+
const result = spawnSync(process.execPath, [script, ...parsed.args], {
|
|
112
|
+
cwd: process.cwd(),
|
|
113
|
+
stdio: 'inherit',
|
|
114
|
+
})
|
|
115
|
+
process.exitCode = result.status ?? 1
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 仅在被直接执行时跑;被测试 import 时只取纯函数
|
|
119
|
+
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
120
|
+
main()
|
|
121
|
+
}
|
|
@@ -41,6 +41,28 @@ function runAudit(script, cwd) {
|
|
|
41
41
|
return { code: res.status, out: (res.stdout ?? '') + (res.stderr ?? '') }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** 同上,但可追加参数(如 --strict / --json) */
|
|
45
|
+
function runAuditWith(script, cwd, extraArgs = []) {
|
|
46
|
+
const res = spawnSync(process.execPath, [join(LINT, script), '--cwd=' + cwd, ...extraArgs], {
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
})
|
|
49
|
+
return { code: res.status, out: (res.stdout ?? '') + (res.stderr ?? '') }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 改写临时消费仓的护栏配置(其余字段保持默认) */
|
|
53
|
+
function writeConfig(root, apps, extra = {}) {
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(root, 'appkit-guardrails.config.json'),
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
apps,
|
|
58
|
+
srcGlobs: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
59
|
+
styleGlobs: ['src/**/*.css'],
|
|
60
|
+
ignore: ['node_modules', 'dist'],
|
|
61
|
+
...extra,
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
test('glob:嵌套路径必须匹配(防「只扫顶层文件」的静默漏扫)', async () => {
|
|
45
67
|
const { globToRegExp } = await import('../shared.mjs')
|
|
46
68
|
const src = globToRegExp('src/**/*.tsx')
|
|
@@ -52,6 +74,33 @@ test('glob:嵌套路径必须匹配(防「只扫顶层文件」的静默漏
|
|
|
52
74
|
assert.ok(css.test('src/features/x/y.css'), '深层 css 应匹配')
|
|
53
75
|
})
|
|
54
76
|
|
|
77
|
+
test('changedFiles:未暂存的新增文件必须纳入(否则「新增文件必须归零」会假绿)', async () => {
|
|
78
|
+
const { changedFiles } = await import('../shared.mjs')
|
|
79
|
+
const root = mkdtempSync(join(tmpdir(), 'appkit-changed-'))
|
|
80
|
+
const git = (...args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' })
|
|
81
|
+
try {
|
|
82
|
+
if (git('init', '-q', '.').status !== 0) return // 无 git:跳过(本机的护栏自测仍覆盖其余规则)
|
|
83
|
+
git('config', 'user.email', 'guardrails@test')
|
|
84
|
+
git('config', 'user.name', 'guardrails')
|
|
85
|
+
writeFileSync(join(root, 'tracked.ts'), 'export const a = 1\n')
|
|
86
|
+
assert.equal(git('add', 'tracked.ts').status, 0)
|
|
87
|
+
if (git('commit', '-qm', 'init').status !== 0) return // 提交失败(无身份/无 hooks 环境):跳过
|
|
88
|
+
|
|
89
|
+
writeFileSync(join(root, 'untracked-new.ts'), 'export const b = 2\n')
|
|
90
|
+
const changed = changedFiles(root)
|
|
91
|
+
assert.ok(changed, '仓库内应返回改动集合(null 表示非 git 环境)')
|
|
92
|
+
assert.ok(
|
|
93
|
+
changed.has('untracked-new.ts'),
|
|
94
|
+
`未 git add 的新增文件必须进集合,实际:${[...changed].join(', ')}`,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
writeFileSync(join(root, 'tracked.ts'), 'export const a = 2\n')
|
|
98
|
+
assert.ok(changedFiles(root).has('tracked.ts'), '已跟踪文件的改动仍须进集合')
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(root, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
55
104
|
test('违规样本:三条护栏都必须报错,且每条违规都带 correction', () => {
|
|
56
105
|
const root = makeConsumer('violations-src')
|
|
57
106
|
try {
|
|
@@ -133,6 +182,111 @@ test('pending 应用:照常报告但不计入退出码', () => {
|
|
|
133
182
|
}
|
|
134
183
|
})
|
|
135
184
|
|
|
185
|
+
test('规则级豁免:只迁骨架(Shell-only)时,结构类纳入门禁、组件与样式类登记豁免', () => {
|
|
186
|
+
const root = makeConsumer('violations-src')
|
|
187
|
+
try {
|
|
188
|
+
// 只迁骨架:结构类立刻要合规(本 fixture 结构违规很多 → 必须仍拦断),
|
|
189
|
+
// 组件直连与样式类先登记豁免。
|
|
190
|
+
writeConfig(root, [
|
|
191
|
+
{
|
|
192
|
+
dir: 'app',
|
|
193
|
+
name: 'fixture-app',
|
|
194
|
+
prefixes: ['other-'],
|
|
195
|
+
pending: false,
|
|
196
|
+
waivedRules: [
|
|
197
|
+
{ rule: 'api/*', reason: 'Shell-only:组件替换在下一批次', since: '2026-09-18' },
|
|
198
|
+
{ rule: 'style/*', reason: '同上:样式收口等骨架稳定后', since: '2026-09-18' },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
// component-audit:违规只剩豁免项 → 退出码 0,但**照常报告**(技术债必须可见)
|
|
204
|
+
const component = runAudit('component-audit.mjs', root)
|
|
205
|
+
assert.equal(component.code, 0, '豁免后不应拦断')
|
|
206
|
+
assert.ok(component.out.includes('api/farris-import'), '豁免项仍必须出现在报告里')
|
|
207
|
+
assert.ok(component.out.includes('豁免:已登记'), '豁免项要标明「已登记」')
|
|
208
|
+
assert.ok(component.out.includes('条属已登记豁免'), '汇总行要给出豁免条数')
|
|
209
|
+
|
|
210
|
+
// style-audit:同理(样式类整类豁免)
|
|
211
|
+
const style = runAudit('style-audit.mjs', root)
|
|
212
|
+
assert.equal(style.code, 0)
|
|
213
|
+
assert.ok(style.out.includes('style/visual'))
|
|
214
|
+
|
|
215
|
+
// structure-audit:没有豁免 → 结构类违规仍拦断(这才是「只迁骨架」的门禁)
|
|
216
|
+
const structure = runAudit('structure-audit.mjs', root)
|
|
217
|
+
assert.equal(structure.code, 1, '结构类规则未豁免,必须仍然拦断')
|
|
218
|
+
assert.ok(structure.out.includes('structure/no-shell'))
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(root, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('规则级豁免:--strict 时豁免一律失效(收口验收口径)', () => {
|
|
225
|
+
const root = makeConsumer('violations-src')
|
|
226
|
+
try {
|
|
227
|
+
writeConfig(root, [
|
|
228
|
+
{
|
|
229
|
+
dir: 'app',
|
|
230
|
+
name: 'fixture-app',
|
|
231
|
+
prefixes: ['other-'],
|
|
232
|
+
pending: true,
|
|
233
|
+
waivedRules: [{ rule: 'style/*', reason: '分阶段迁移', since: '2026-09-18' }],
|
|
234
|
+
},
|
|
235
|
+
])
|
|
236
|
+
assert.equal(runAudit('style-audit.mjs', root).code, 0, '平时:豁免 + 挂起 → 不拦断')
|
|
237
|
+
assert.equal(runAuditWith('style-audit.mjs', root, ['--strict']).code, 1, '--strict:豁免与挂起都失效')
|
|
238
|
+
} finally {
|
|
239
|
+
rmSync(root, { recursive: true, force: true })
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('规则级豁免:0 条命中时提示可撤销(豁免只该在「还没迁到那层」期间存在)', () => {
|
|
244
|
+
const root = makeConsumer('clean-src')
|
|
245
|
+
try {
|
|
246
|
+
writeConfig(root, [
|
|
247
|
+
{
|
|
248
|
+
dir: 'app',
|
|
249
|
+
name: 'fixture-app',
|
|
250
|
+
prefixes: ['other-'],
|
|
251
|
+
pending: false,
|
|
252
|
+
waivedRules: [{ rule: 'api/*', reason: '上一批次留下的', since: '2026-09-01' }],
|
|
253
|
+
},
|
|
254
|
+
])
|
|
255
|
+
const { code, out } = runAudit('component-audit.mjs', root)
|
|
256
|
+
assert.equal(code, 0)
|
|
257
|
+
assert.ok(out.includes('可以撤销'), `0 条命中应提示撤销,实际输出:${out}`)
|
|
258
|
+
assert.ok(out.includes('api/*'))
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(root, { recursive: true, force: true })
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('规则级豁免:配置缺 reason 时直接报错(不留静音键)', () => {
|
|
265
|
+
const root = makeConsumer('clean-src')
|
|
266
|
+
try {
|
|
267
|
+
writeConfig(root, [
|
|
268
|
+
{ dir: 'app', name: 'fixture-app', prefixes: ['other-'], waivedRules: [{ rule: 'api/*' }] },
|
|
269
|
+
])
|
|
270
|
+
const { code, out } = runAudit('component-audit.mjs', root)
|
|
271
|
+
assert.equal(code, 2, '配置错误应走「环境问题」退出码,而不是静默通过')
|
|
272
|
+
assert.ok(out.includes('缺少 reason'))
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(root, { recursive: true, force: true })
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('matchRule:精确名与整类通配各按各的匹配,不做中间通配', async () => {
|
|
279
|
+
const { matchRule } = await import('../shared.mjs')
|
|
280
|
+
assert.equal(matchRule('api/farris-import', 'api/farris-import'), true)
|
|
281
|
+
assert.equal(matchRule('api/*', 'api/legacy-prop'), true)
|
|
282
|
+
assert.equal(matchRule('api/*', 'style/visual'), false)
|
|
283
|
+
assert.equal(matchRule('structure/*', 'structure/no-shell'), true)
|
|
284
|
+
// 中间通配不实现:写出来会让人以为能精确匹配
|
|
285
|
+
assert.equal(matchRule('style/*-element', 'style/bare-element'), false)
|
|
286
|
+
assert.equal(matchRule('', 'api/legacy-prop'), false)
|
|
287
|
+
assert.equal(matchRule(undefined, 'api/legacy-prop'), false)
|
|
288
|
+
})
|
|
289
|
+
|
|
136
290
|
test('--changed:非 git 环境下不裁剪(宁可多检查)', () => {
|
|
137
291
|
const root = makeConsumer('violations-src')
|
|
138
292
|
try {
|
package/lint/component-audit.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `selection={{…}}` 是 app-kit 自己的结构化契约,**合法**;
|
|
14
14
|
* 而 `rowOption` / `columnOption` / `enableSelectRow` / `enabelSelectRow` 等属于底层,一律禁止。
|
|
15
15
|
*
|
|
16
|
-
* 用法:
|
|
16
|
+
* 用法:pnpm exec appkit lint:component [--json] [--changed]
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync } from 'node:fs'
|
|
19
19
|
import { join } from 'node:path'
|
|
@@ -30,6 +30,34 @@
|
|
|
30
30
|
"type": "boolean",
|
|
31
31
|
"default": false,
|
|
32
32
|
"description": "存量挂起:为 true 时该应用仅产出基线报告,不计入退出码(迁移完成后置 false 即自动纳入门禁)。"
|
|
33
|
+
},
|
|
34
|
+
"waivedRules": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"description": "规则级豁免(**分阶段迁移**用,典型是「只迁骨架 / Shell-only」):登记的规则照常报告但不计入退出码。要收口时跑 `--strict` 验证,并在撤销豁免后纳入门禁。规则名支持 `域/*` 整类通配(如 structure/*);每条必须写 reason。",
|
|
37
|
+
"items": {
|
|
38
|
+
"oneOf": [
|
|
39
|
+
{ "type": "string", "description": "简写形式(不推荐:没有理由,无法审计)" },
|
|
40
|
+
{
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["rule", "reason"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"rule": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "规则名(如 api/farris-import)或整类通配(如 style/*)。"
|
|
47
|
+
},
|
|
48
|
+
"reason": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "为什么暂时不查 + 什么时候收口(如「只迁骨架,组件替换在下一批次」)。必填。"
|
|
51
|
+
},
|
|
52
|
+
"since": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "登记日期(ISO 8601,如 2026-09-18)。"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"additionalProperties": false
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
33
61
|
}
|
|
34
62
|
}
|
|
35
63
|
}
|
|
@@ -69,6 +97,20 @@
|
|
|
69
97
|
"exemptionRegistry": "docs/appkit-exemptions.md",
|
|
70
98
|
"contractDoc": "node_modules/@manohub/app-kit/CONTRACT.md",
|
|
71
99
|
"ignore": ["node_modules", "dist"]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"apps": [
|
|
103
|
+
{
|
|
104
|
+
"dir": ".",
|
|
105
|
+
"name": "shell-only-app",
|
|
106
|
+
"prefixes": ["so-"],
|
|
107
|
+
"pending": false,
|
|
108
|
+
"waivedRules": [
|
|
109
|
+
{ "rule": "api/*", "reason": "只迁骨架(Shell-only):组件替换在下一批次", "since": "2026-09-18" },
|
|
110
|
+
{ "rule": "style/*", "reason": "同上:样式收口等骨架稳定后再做", "since": "2026-09-18" }
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
]
|
|
72
114
|
}
|
|
73
115
|
]
|
|
74
116
|
}
|