@rt-tools/agent-kit 0.5.2 → 0.6.0

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.
Files changed (52) hide show
  1. package/README.md +34 -6
  2. package/assets/checks/check-file-size.mjs +127 -0
  3. package/assets/checks/rt-kit-checks.config.mjs +10 -0
  4. package/assets/defaults/gate-map.sh +90 -34
  5. package/assets/defaults/project.sh +26 -3
  6. package/assets/hooks/skill-gate-layers.sh +156 -0
  7. package/assets/hooks/skill-gate.sh +11 -2
  8. package/assets/laws/code-structure.md +3 -0
  9. package/assets/laws/delivery.md +9 -0
  10. package/assets/laws/observability.md +46 -0
  11. package/assets/laws/project-documentation.md +4 -0
  12. package/assets/laws/reuse-first.md +2 -0
  13. package/assets/laws/verifiability.md +5 -0
  14. package/assets/patterns/browser-verification-stand.md +22 -2
  15. package/assets/patterns/doc-style-trace.md +111 -0
  16. package/assets/patterns/git-workflow-commit.github.md +1 -1
  17. package/assets/patterns/git-workflow-docker.md +203 -0
  18. package/assets/patterns/git-workflow-secrets.md +93 -0
  19. package/assets/patterns/observability-record.md +114 -0
  20. package/assets/patterns/ownership-session-procedure.md +102 -0
  21. package/assets/patterns/seo-verify.md +1 -1
  22. package/assets/patterns/spec-driven-rule.md +5 -0
  23. package/assets/patterns/styling-bem-sheet.md +178 -0
  24. package/assets/patterns/task-flow-close.md +20 -0
  25. package/assets/patterns/task-flow-resume.md +5 -0
  26. package/assets/patterns/translations-content.md +107 -0
  27. package/assets/patterns/translations-key.md +1 -1
  28. package/assets/rules/angular-patterns.md +5 -0
  29. package/assets/rules/browser-verification.md +17 -12
  30. package/assets/rules/component-structure.md +6 -2
  31. package/assets/rules/doc-style.md +16 -0
  32. package/assets/rules/git-workflow.azure.md +45 -1
  33. package/assets/rules/git-workflow.github.md +52 -1
  34. package/assets/rules/git-workflow.gitlab.md +46 -1
  35. package/assets/rules/lists.md +13 -0
  36. package/assets/rules/observability.md +147 -0
  37. package/assets/rules/ownership-scope.md +5 -2
  38. package/assets/rules/ownership-session.md +124 -0
  39. package/assets/rules/permissions.md +23 -0
  40. package/assets/rules/pricing.md +4 -0
  41. package/assets/rules/reuse-first.md +9 -0
  42. package/assets/rules/seo.md +57 -9
  43. package/assets/rules/shared-code.md +6 -0
  44. package/assets/rules/spec-driven.md +9 -0
  45. package/assets/rules/styling-bem.md +34 -1
  46. package/assets/rules/task-flow.md +5 -0
  47. package/assets/rules/testing.md +46 -8
  48. package/assets/rules/translations.md +11 -5
  49. package/assets/rules/typescript-conventions.md +5 -0
  50. package/package.json +1 -1
  51. package/rt-tools-agent-kit-0.6.0.tgz +0 -0
  52. package/rt-tools-agent-kit-0.5.2.tgz +0 -0
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: observability-record
3
+ kind: pattern
4
+ rule: observability
5
+ description: Паттерн правила observability. Брать, когда в коде заводится новая строка лога — готовый вызов логгера, выбор уровня, имя строки, поля объектом, отказ внешней службы и предел ожидания. Не брать для правки самого логгера и контекста запроса — это правило observability.
6
+ ---
7
+
8
+ # Новая строка лога
9
+
10
+ Паттерн правила `observability`. Что при этом должно быть верно — закон
11
+ `docs/constitution/observability.md`.
12
+
13
+ ## Когда брать
14
+
15
+ - В домене появилось место, о котором владелец должен узнать: не сработала автоматика,
16
+ отказала внешняя служба, упала процедура.
17
+ - Хочется поставить `console.log` — вместо него ставится строка лога.
18
+
19
+ ## Логгер домена берётся один раз
20
+
21
+ ```ts
22
+ const LOG_CONTEXT: string = 'PageCache';
23
+
24
+ readonly #log: ScopedLogger;
25
+
26
+ constructor(logger: AppLoggerService) {
27
+ this.#log = logger.scope(LOG_CONTEXT);
28
+ }
29
+ ```
30
+
31
+ Контекст задаётся один раз и константой модуля, а не литералом в вызове: соседние классы одного
32
+ домена пишут под тем же источником, а отбор отказов судит именно по нему. Номер обращения,
33
+ процедуру и того, кто в системе, логгер подставляет сам — передавать их полями не надо.
34
+
35
+ ## Имя строки постоянное, всё изменяемое — в поля
36
+
37
+ ```ts
38
+ ✓ this.#log.warn('cache.refresh.rejected', { url, attempt, status, durationMs });
39
+ ✗ this.#log.warn(`cache refresh rejected for ${url} after ${attempt} attempts`);
40
+ ```
41
+
42
+ Первый аргумент — имя строки, а не предложение. По нему строки об одном и том же собираются
43
+ вместе, и по нему же они схлопываются в одну строку ленты отказов. Подставленное в имя значение
44
+ делает каждую строку уникальной.
45
+
46
+ Имя пишется точками от общего к частному: `cache.refresh.rejected`, `mail.owner.skipped`.
47
+
48
+ Поля вычищаются автоматически. Имя строки проходит вычистку только по пути в хранилище отказов
49
+ — в выводе оно печатается как есть, — поэтому подставлять в него значения нельзя ещё и по этой
50
+ причине.
51
+
52
+ ## Строке нижнего уровня нужна строка в списке отобранных
53
+
54
+ Уровень отказа и всё, что выше, уезжает в хранилище отказов само. Строка уровнем ниже попадает
55
+ туда, только если её имя стоит в списке отобранных имён. Список сравнивается по началу имени и
56
+ правится тем же коммитом, что и место, которое эту строку пишет.
57
+
58
+ Забыли дописать — строка останется только в выводе контейнера, и владелец о ней не узнает. Не
59
+ сверяет это ничто.
60
+
61
+ ## Уровень выбирается по тому, сломалось ли что-то
62
+
63
+ | Уровень | Когда |
64
+ | ----------- | -------------------------------------------------------------------------------------------------------- |
65
+ | отказ | приложение не сделало того, что должно: упала процедура, не прошла запись в хранилище |
66
+ | ниже | не сработала автоматика: перевод, обновление кэша, опрос внешней службы. Сюда же отказ по вводу и правам |
67
+ | сообщение | состоявшееся действие, о котором стоит знать: вызов прошёл, письмо ушло |
68
+ | подробность | то, что нужно при разборе, а на проде не нужно |
69
+
70
+ Отказ гостя пройти проверку, промах в форме и «не найдено» — это уровень ниже отказа. Проверка
71
+ сработала, приложение работает.
72
+
73
+ ## Отказ внешней службы записывается разобранным
74
+
75
+ ```ts
76
+ } catch (error: unknown) {
77
+ this.#log.error('mail.send.failed', { to: maskEmail(to), error: describeError(error) });
78
+ }
79
+ ```
80
+
81
+ Разбор причины кладёт класс, текст, код и срезанный стек одной формой — той же, что у остальных
82
+ отказов. Подставлять ошибку в текст (`` `failed: ${String(error)}` ``) нельзя: так в лог уедет
83
+ чужой текст целиком, вместе с адресом, ключом или почтой гостя.
84
+
85
+ Почту маскируют на месте вызова, если она приходит отдельным значением: вычистка узнаёт её по
86
+ имени поля, а имя вроде `to` на почту не похоже.
87
+
88
+ ## Отказ наступает только тогда, когда у обращения есть предел ожидания
89
+
90
+ ```ts
91
+ /**
92
+ * Ожидание источника курсов. Курс справочный и обновляется раз в сутки: пять
93
+ * секунд ожидания и прежний курс на месте лучше, чем пять минут занятого
94
+ * расписания.
95
+ */
96
+ const RATES_TIMEOUT_MS: number = 5_000;
97
+
98
+ const response: Response = await fetch(RATES_URL, { signal: AbortSignal.timeout(RATES_TIMEOUT_MS) });
99
+ ```
100
+
101
+ Без предела соединение с молчащей службой живёт до умолчания среды — минуты, — и всё это время
102
+ `catch` не наступает: писать нечего. Число стоит рядом с клиентом со своим доводом, потому что
103
+ цена ожидания у каждой службы своя: гость внутри запроса ждёт иначе, чем ночное расписание.
104
+
105
+ ## Частые промахи
106
+
107
+ - **Одно и то же имя строки стоит в двух местах.** Тогда две разные ситуации читаются как одна.
108
+ Либо имена разные, либо место одно.
109
+ - **`console.log` вместо строки лога.** У него нет ни уровня, ни номера обращения, и в отбор он
110
+ не попадает. На проде это строка, которую никто не увидит.
111
+ - **Уровень выбран по тому, насколько неприятно.** Отказ гостя пройти проверку неприятен, но
112
+ приложение при этом работает.
113
+ - **Ошибка положена в поле как есть, без разбора причины.** Объект ошибки сериализуется в
114
+ пустой `{}`, и в логе не останется ни текста, ни класса.
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: ownership-session-procedure
3
+ kind: pattern
4
+ rule: ownership-session
5
+ description: Паттерн правила ownership-session. Брать, когда процедуре нужна владеющая сущность захода — объявление декоратором, чтение из контекста, отбивка чужой сущности в черновике, контекст в тесте. Не брать для объявления доступа к процедуре — это паттерн permissions-procedure, и не для формы самого класса — это паттерн ts-procedure.
6
+ ---
7
+
8
+ # Процедура, которой нужна сущность захода
9
+
10
+ Паттерн правила `ownership-session`. Что при этом должно быть верно — закон
11
+ `docs/constitution/application/ownership.md`.
12
+
13
+ ## Когда брать
14
+
15
+ - Процедура работает с записями владеющей сущности и должна знать, в какой она работает.
16
+ - Процедура читает идентификатор сущности из полей запроса — так больше не делают.
17
+ - Пишется тест процедуры, которая сущность захода читает.
18
+
19
+ ## Объявление и чтение
20
+
21
+ Две строки пишутся вместе: декоратор на классе и чтение из контекста в теле. Перехватчик читает
22
+ сущность только для объявивших, поэтому чтение без декоратора отвечает внутренней ошибкой — и
23
+ отвечает на первом вызове, а не на сборке.
24
+
25
+ ```ts
26
+ @Injectable()
27
+ @ConnectProcedure()
28
+ @RequiresPermission('settings:view')
29
+ @RequiresOwner()
30
+ export class GetSettingsProcedure implements IConnectProcedure<typeof SettingsContract.method.getSettings> {
31
+ public async handle(
32
+ _request: GetSettingsRequest,
33
+ context: HandlerContext
34
+ ): Promise<MessageInitShape<typeof GetSettingsResponseSchema>> {
35
+ const settings: IOwnerSettings = await this.#settings.byOwner(ownerOf(context));
36
+
37
+ }
38
+ }
39
+ ```
40
+
41
+ Запрос при этом не читается вовсе — отсюда `_request`. Поля сущности в контракте нет: номер и
42
+ имя помечены занятыми, а присланный ключ отбивается разбором запроса. Заводить его заново
43
+ нельзя: сущность в запросе называет только тот, кто работает поверх всех сразу.
44
+
45
+ Декоратор потребности стоит рядом с объявлением доступа, а не вместо него: объявлений доступа у
46
+ процедуры по-прежнему ровно одно, и без него приложение не поднимется.
47
+
48
+ ## Идентификатор, названный в теле запроса
49
+
50
+ Некоторые запросы несут сущность не отдельным полем, а внутри черновика. Расхождение с
51
+ сущностью захода отбивается, а не выправляется молча:
52
+
53
+ ```ts
54
+ const sessionOwnerId: string = ownerOf(context);
55
+ if (draft.id && draft.id !== sessionOwnerId) {
56
+ throw new ConnectError('owner does not match the session owner', Code.InvalidArgument);
57
+ }
58
+ ```
59
+
60
+ Пустой идентификатор в черновике законен: он приходит пустым, когда админка сохраняет то, чего
61
+ ещё не загружала.
62
+
63
+ ## Контекст в тесте
64
+
65
+ Двойника перехватчика заводить не надо — в контекст кладётся готовое значение:
66
+
67
+ ```ts
68
+ function context(ownerId: string): HandlerContext {
69
+ const values: ContextValues = createContextValues();
70
+ values.set(kSessionOwner, ownerId);
71
+
72
+ return { values } as unknown as HandlerContext;
73
+ }
74
+ ```
75
+
76
+ Тест на то, что присланный идентификатор не читается, пишется отдельным случаем: запрос несёт
77
+ чужую сущность, а спросили у хранилища свою.
78
+
79
+ ```ts
80
+ it('SC-OWN-54 — читает сущность захода и присланный идентификатор не смотрит', async () => {
81
+ const request: GetSettingsRequest = create(GetSettingsRequestSchema, { ownerId: FOREIGN_OWNER });
82
+
83
+ await procedure.handle(request, context(SESSION_OWNER));
84
+
85
+ expect(asked).toEqual([SESSION_OWNER]);
86
+ });
87
+ ```
88
+
89
+ ## Частые промахи
90
+
91
+ - Декоратор забыт, а чтение в теле стоит. Сборка и линт молчат, отказ приходит на первом вызове.
92
+ Ловится тестом процедуры, а не проверками дерева.
93
+ - Декоратор поставлен вместо объявления доступа. Приложение не поднимется: у процедуры обязано
94
+ быть ровно одно объявление доступа, а потребность в сущности к ним не относится.
95
+ - Сущность прочитана из запроса «на всякий случай, если контекст пуст». Пустой контекст здесь
96
+ означает забытое объявление, и подстановка прячет его до самого прода.
97
+ - Процедура читает сущность захода у гостя. Вошедшего у гостевых процедур нет, и сущности в
98
+ контексте у них тоже нет: они берут её у записи, с которой пришли, — у страницы, у разговора
99
+ по токену, — а когда записи нет вовсе, у единственной сущности хранилища. Второй в хранилище
100
+ они отвечают отказом по состоянию.
101
+ - Отказ на снятую принадлежность подан как отказ в доступе. Транспорт админки уводит на вход
102
+ только по коду неаутентифицированного, а на второй код человек остаётся на закрытом разделе.
@@ -32,7 +32,7 @@ PORT={{prodSitePort}} node dist/apps/site/server/server.mjs &
32
32
  ## Что смотреть в отданном HTML
33
33
 
34
34
  ```bash
35
- for locale in "" ru/ de/ zh-Hans/ zh-Hant/ ko/ th/ hi/; do
35
+ for locale in "" <префиксы локалей>; do
36
36
  printf '%-10s ' "${locale:-en}"
37
37
  curl -s -H "Host: localhost" "http://localhost:{{prodSitePort}}/${locale}<адрес страницы>" \
38
38
  | grep -c -E '<title>|name="description"|property="og:|rel="canonical"|hreflang=|application/ld\+json'
@@ -125,3 +125,8 @@ description: Паттерн правила git-workflow. Брать … Не б
125
125
  проверка перестанет её находить.
126
126
  - В `description` не сказано, когда паттерн **не** брать, — соседний паттерн того же правила
127
127
  становится неотличимым.
128
+ - Блок готового кода принят по виду, а не сверкой с объявлением. Вызов в примере повторяет имя,
129
+ число и порядок параметров живой функции: двухпараметрный вызов выглядел правдоподобно ровно
130
+ до того, как рядом открыли четырёхпараметрное объявление. Тем же проходом сверяется форма
131
+ кода — пример, объявляющий поля не так, как их объявляет дерево, учит нарушать правило о
132
+ языке, и запрещающее правило про это не узнает.
@@ -0,0 +1,178 @@
1
+ ---
2
+ name: styling-bem-sheet
3
+ kind: pattern
4
+ rule: styling-bem
5
+ description: Паттерн правила styling-bem. Брать при заведении или правке шторки, окна и полноэкранного просмотра поверх страницы — чем открывается, что передаётся внутрь, как приезжает снизу, чем проверяется. Не брать для панели правки записи в админке — это паттерн entity-aside.
6
+ ---
7
+
8
+ # Шторка и окно поверх страницы
9
+
10
+ Паттерн правила `styling-bem`. Что при этом должно быть верно — закон
11
+ `docs/constitution/frontend-application.md`.
12
+
13
+ ## Когда брать
14
+
15
+ - На телефоне заводится шторка: выбор языка, валюты, дат.
16
+ - Заводится окно или полноэкранный просмотр поверх страницы.
17
+ - Шторку или окно что-то перекрывает, и хочется поднять им `z-index`.
18
+
19
+ Панель правки записи в админке — это другое: там маршрут в своём аутлете, паттерн
20
+ `entity-aside`.
21
+
22
+ ## Открывает служба, а не разметка рядом с кнопкой
23
+
24
+ Компонент шторки — обычный элемент разметки. Он рисуется там, где написан, поэтому его
25
+ `z-index` сравнивается только с соседями по этому месту. Липкая шапка размывает фон под собой —
26
+ и всё, что написано внутри шапки, замкнуто в её слой.
27
+
28
+ Открывает шторку служба окон кита — тем же вызовом открывается полноэкранный просмотр.
29
+ Разметка уезжает к `<body>`, и сравнивать её становится не с чем.
30
+
31
+ ```typescript
32
+ readonly #dialog: RtDialogService = inject(RtDialogService);
33
+
34
+ readonly #sheetOpenedSource: Subject<RtDialogRef<string>> = new Subject<RtDialogRef<string>>();
35
+ #sheetRef: RtDialogRef<string> | null = null;
36
+
37
+ protected openLocalePicker(): void {
38
+ if (this.#sheetRef) {
39
+ return;
40
+ }
41
+
42
+ const data: ILocalePickerData = { locales: this.locales, value: this.currentLocale };
43
+ const ref: RtDialogRef<string> = this.#dialog.open<LocalePickerComponent, ILocalePickerData, string>(
44
+ LocalePickerComponent,
45
+ { data, panelClass: 'locale-picker-panel', backdropClass: 'locale-picker-backdrop' }
46
+ );
47
+ this.#sheetRef = ref;
48
+ this.#sheetOpenedSource.next(ref);
49
+ }
50
+ ```
51
+
52
+ Ответ шторки ждут подпиской из конструктора — подписываться в методе запрещает
53
+ `angular-patterns`:
54
+
55
+ ```typescript
56
+ this.#sheetOpenedSource
57
+ .pipe(
58
+ mergeMap((ref: RtDialogRef<string>): Observable<string | undefined> => ref.afterClosed()),
59
+ takeUntilDestroyed(this.#destroyRef)
60
+ )
61
+ .subscribe((code: string | undefined): void => {
62
+ this.#sheetRef = null;
63
+ if (code) {
64
+ this.onLocaleChange(code);
65
+ }
66
+ });
67
+ ```
68
+
69
+ Ссылку обнуляют здесь, а не в обработчике кнопки: шторку закрывают ещё жестом вниз и нажатием
70
+ мимо, и оба пути идут мимо кнопки.
71
+
72
+ ## Внутри шторки — только она сама
73
+
74
+ Что показать, приходит токеном. Что выбрали, уходит вместе с закрытием. Своей кнопки у шторки
75
+ нет: кнопку держит тот, кто шторку открывает.
76
+
77
+ ```typescript
78
+ export interface ILocalePickerData {
79
+ readonly locales: readonly ISiteLocale[];
80
+ readonly value: string;
81
+ }
82
+
83
+ readonly #data: ILocalePickerData = inject<ILocalePickerData>(RT_DIALOG_DATA);
84
+ readonly #dialogRef: RtDialogRef<string> = inject<RtDialogRef<string>>(RtDialogRef);
85
+
86
+ protected confirm(): void {
87
+ this.#dialogRef.close(this.centeredLocale());
88
+ }
89
+
90
+ /** Закрыли, не подтвердив: значит, передумали — значение не меняем */
91
+ protected onOpenChange(open: boolean): void {
92
+ this.open.set(open);
93
+ if (!open) {
94
+ this.#dialogRef.close();
95
+ }
96
+ }
97
+ ```
98
+
99
+ ## Шторка приезжает снизу
100
+
101
+ Служба поднимает её сразу открытой, и анимировать киту нечего: вместо движения гость видит
102
+ подмену экрана. Поэтому открывают её на кадр позже:
103
+
104
+ ```typescript
105
+ protected readonly open: WritableSignal<boolean> = signal<boolean>(false);
106
+
107
+ constructor() {
108
+ afterNextRender((): void => {
109
+ this.open.set(true);
110
+ });
111
+ }
112
+ ```
113
+
114
+ `afterNextRender`, а не `ngAfterViewInit`: страницу отдаёт сервер, и разметка появляется позже.
115
+
116
+ ## Подложка
117
+
118
+ Шторка сама затемняет фон и сама ловит нажатие мимо. Подложка окна поверх неё дала бы второе
119
+ затемнение — фон стал бы вдвое темнее, чем у соседней шторки. Поэтому её делают прозрачной.
120
+ Правило пишут в общий слой приложения: шторка уехала к `<body>`, и файл стилей компонента до
121
+ неё не достаёт.
122
+
123
+ ```scss
124
+ /* общий слой приложения; имя класса несёт приставку дерева */
125
+ .locale-picker-backdrop {
126
+ background: transparent;
127
+ }
128
+ ```
129
+
130
+ Нажатие она ловит по-прежнему — закрытие по ней делает само окно.
131
+
132
+ ## Чем проверяется
133
+
134
+ Скриншот тут не поможет: перекрытая шторка выглядит целой, а сборка и линт молчат. Спрашивают
135
+ точку — кому достанется нажатие:
136
+
137
+ ```javascript
138
+ const rect = button.getBoundingClientRect();
139
+ document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
140
+ ```
141
+
142
+ Ответом должна быть сама кнопка. Мерить надо на стенде из прод-сборки и на узком экране —
143
+ паттерн `browser-verification-measure`.
144
+
145
+ В сквозном тесте перед замером дожидаются, пока шторка доедет: видимая кнопка ещё может
146
+ двигаться, и координаты через кадр будут другими.
147
+
148
+ ```typescript
149
+ await page.locator('[qa-dataid="locale-picker-sheet"] [qa-dataid="bottom-sheet-panel"]').evaluate(
150
+ (panel: Element): Promise<void> =>
151
+ new Promise<void>((resolve: () => void): void => {
152
+ if (panel.getBoundingClientRect().bottom <= window.innerHeight) {
153
+ resolve();
154
+
155
+ return;
156
+ }
157
+ panel.addEventListener('transitionend', (): void => resolve(), { once: true });
158
+ })
159
+ );
160
+ ```
161
+
162
+ ## Частые промахи
163
+
164
+ - **Шторка написана рядом со своей кнопкой в шапке.** Шапка размывает фон, и шторка замкнута в
165
+ её слой. Так нижняя панель накрыла шторку вместе с кнопкой подтверждения, и значение на
166
+ телефоне стало не сменить.
167
+ - **Шапке подняли номер слоя.** Дефект уходит, шторка остаётся замкнутой в чужой слой:
168
+ следующий сосед с номером повыше накроет её снова.
169
+ - **Шторку перенесли в другое место разметки.** То же самое: заработает, пока над новым местом
170
+ никто не поставит `transform`, `filter` или свой `z-index`.
171
+ - **Правило подложки положили в стили компонента.** Подложка уехала к `<body>`, и правило до
172
+ неё не достаёт — писать надо в общий слой приложения.
173
+ - **Ссылку на открытую шторку обнулили в обработчике кнопки.** Закрытие жестом и нажатием мимо
174
+ идёт мимо него, и второй раз шторка уже не откроется.
175
+ - **Тест померил сразу после проверки видимости.** Шторка ещё едет, `elementFromPoint`
176
+ возвращает `null`, и тест краснеет на исправном коде.
177
+ - **Образец искали по именам библиотеки, поверх которой собран кит.** Она лежит внутри кита, её
178
+ имён в дереве нет — искать надо по именам кита.
@@ -148,7 +148,27 @@ npm run check:docs # пути, названные в текстах, суще
148
148
  выкачено» с тем, что работает месяц, — беззвучная ложь, тем убедительнее, чем старше.
149
149
  - **Номера сценариев при вливании не пересчитываются.** Идентификатор — ключ связи с тестами;
150
150
  сдвиг рвёт сверку у соседей, которых правка не касалась.
151
+ - **«Что не входит» после вливания читается целиком, а не дописывается.** Договорённость несёт
152
+ свои границы, и при вливании они ложатся рядом с границами домена: половина повторяет уже
153
+ стоявшее другими словами, а строка «этого раздела ещё нет» становится ложью ровно той
154
+ работой, которая её вливает. Сверка спеков в этот раздел не смотрит вовсе. Снимается
155
+ дословный повтор и то, что работа сделала входящим.
156
+ - **Шаги закрытия с владельцем не согласуются — они перечислены здесь.** Разбор работы
157
+ правилами входит в закрытие так же, как вливание договорённости и разбор папки; владелец
158
+ решает не то, запускать ли его, а что делать с находками. Ход, кончившийся таким вопросом,
159
+ отбивает гард разговора: за ход правила не читались, а ответ стоит в них. Спрашивается только
160
+ то, чего в правилах нет.
161
+ - **Блок готового кода в паттерне стареет от чужой правки.** Он не привязан ни к чему: сверка
162
+ спеков читает утверждения правила, а пример под ними не читает вовсе. Два поля, ставших
163
+ обязательными в чужой работе, сделали пример в соседнем паттерне несобираемым — сам он при
164
+ этом не изменился ни на знак и в след задачи не попал, потому что ни одного слова той работы
165
+ в нём нет. Паттерн находится по имени правленого символа, а не по теме работы.
151
166
  - **Правило без привязки в спек домена не въезжает.** Кода, который его исполняет, нет —
152
167
  значит это намерение, и место ему в открытых вопросах домена, а не в правилах.
168
+ - **Замысел линии работ правят только там, где вписывают «чем кончилась».** Границы линии и
169
+ порядок задач в ней при этом остаются прежними, а работа их уже нарушила: задача, решившая
170
+ читать спеки, оставила над собой границу «спеки — вторая очередь», и следующий исполнитель
171
+ прочитает её как действующую. Границы линии перечитываются целиком тем же заходом, что и
172
+ итог работы.
153
173
  - **Архив не обновляется после выкатки.** Уехавшее туда описывает день переезда, и правится
154
174
  оно только вместе с признанием, что описывало неверно.
@@ -102,5 +102,10 @@ git log --oneline origin/main..HEAD
102
102
  несохранённым и почему» — единственное, по чему это видно, пока отчёта нет.
103
103
  - **Подтверждение — вывод команды или замер, а не пересказ.** «Проверил, работает» через
104
104
  заход неотличимо от «казалось, что работает».
105
+ - **Число из передачи пересчитывается до того, как на нём что-то делят.** Передача описывает
106
+ день, когда её писали, лежит вне дерева, и не читает её ни одна проверка. Оценка «работы
107
+ вдвое больше предыдущей» при пересчёте на текущем коммите не подтвердилась: по числу
108
+ утверждений объёмы оказались почти равны. Деление работы по чужой оценке делит не то, и
109
+ владельцу называются свои числа.
105
110
  - **Доэтапное отделяется от своего.** Красное, найденное по дороге и не этой работой
106
111
  сделанное, помечается таковым сразу: иначе следующий заход примет его за свою поломку.
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: translations-content
3
+ kind: pattern
4
+ rule: translations
5
+ description: Паттерн правила translations. Брать при работе с переводами содержимого записи — производный перевод при сохранении, запись локали руками через язык админки, разбор отказа провайдера, сброс кэша отданных страниц. Не брать для подписей интерфейса — это паттерн translations-key.
6
+ ---
7
+
8
+ # Перевод содержимого записи
9
+
10
+ Паттерн правила `translations`. Что при этом должно быть верно — закон
11
+ `docs/constitution/application/locales.md`.
12
+
13
+ ## Когда брать
14
+
15
+ - Владелец правит тексты записи, и остальные локали должны догнать.
16
+ - Админка ответила, что сохранила на языке ввода, а перевод на остальные не удался.
17
+ - Локаль надо заполнить руками: провайдер недоступен, а страница на этом языке нужна сегодня.
18
+ - Проверяется, что отданная страница показывает свежий текст.
19
+
20
+ ## Перевод идёт сам, и просить его нечем
21
+
22
+ Кнопки «перевести» нет. Владелец печатает на своём языке, а процедура сохранения зовёт перевод
23
+ до транзакции и кладёт в хранилище уже все локали. Локаль ввода приезжает полем запроса и
24
+ берётся из языка админки, а не из браузера гостя.
25
+
26
+ В партию идёт не всё подряд: сборщик отбрасывает строку, чей исходный текст не менялся **и**
27
+ которая переведена на все целевые локали. Отсюда два следствия:
28
+
29
+ - сохранение записи без правки текстов провайдера не зовёт вовсе — перестановка изображения или
30
+ правка числа ничего не стоит;
31
+ - строка, у которой хоть одна локаль пуста, поедет на перевод при первом же сохранении, без
32
+ правки самого текста.
33
+
34
+ ## Локаль пишется руками через язык админки
35
+
36
+ Табов локалей в форме нет, и это не упущение: поле одно, а какая локаль за ним стоит, решает
37
+ язык интерфейса. Значит записать перевод руками — это переключить язык в шапке админки и
38
+ заполнить те же поля.
39
+
40
+ Путь этот не аварийный: пока у провайдера пуст счёт, он и есть обычный порядок, и каждый
41
+ видимый текст заводится по разу на локаль. Считать его временным и ждать автоперевода не надо.
42
+
43
+ Порядок такой:
44
+
45
+ 1. Меню владельца в шапке → выбрать язык.
46
+ 2. Открыть запись. Текстовые поля покажут текст **этой** локали; пустые они там, где локали у
47
+ записи нет.
48
+ 3. Заполнить и сохранить.
49
+
50
+ Соседние локали при этом целы: черновик пишет в карту по коду локали, а остальные ключи уезжают
51
+ в запрос как пришли. Отказ провайдера ничего не портит — в хранилище ложится ровно то, что
52
+ прислала форма.
53
+
54
+ Записанное руками потом не перетирается: у поля заполнены все локали, и сборщик партии больше
55
+ не берёт его, пока владелец не изменит исходный текст.
56
+
57
+ Собирать вызов сохранения скриптом для этого не надо, и правило это прямо запрещает: черновик
58
+ перезаписывает запись целиком, а вложенная запись, не попавшая в список, удаляется вместе со
59
+ своими связями.
60
+
61
+ ## Отказ провайдера читается не по экрану
62
+
63
+ Владельцу показывается одна фраза без причины. Причина живёт в логах строкой своего контекста:
64
+
65
+ ```bash
66
+ docker compose -f <состав прода> --env-file <файл окружения> logs api --since 30m \
67
+ | grep -i translation
68
+ ```
69
+
70
+ Строка несёт код ответа, но не текст: клиент бросает отказ по коду, не читая тела. Что именно
71
+ ответил провайдер, спрашивается у него напрямую тем же ключом; для разового вызова его берут
72
+ руками — приложение эту переменную не читает, но значение там то же, что владелец завёл в
73
+ админке. Отказ по деньгам означает пустой счёт, а не сломанный ключ: список моделей при этом
74
+ отдаётся, и строка интеграции показывает «работает». Где лежит сам ключ и почему проба
75
+ зелёная — паттерн `git-workflow-secrets`.
76
+
77
+ ## Отданная страница показывает прежний текст, пока не сброшен кэш
78
+
79
+ Записанный перевод виден в хранилище сразу, а гостю — нет: страницы лежат в кэше прокси. На
80
+ проде он сбрасывается так же, как на стенде:
81
+
82
+ ```bash
83
+ docker exec <контейнер прокси> sh -c 'rm -rf /var/cache/nginx/site/*; nginx -s reload'
84
+ ```
85
+
86
+ Сверяется отданной разметкой по каждой правленой локали, а не взглядом на админку:
87
+
88
+ ```bash
89
+ for l in <локали>; do
90
+ printf '%-8s ' "$l"
91
+ curl -sL "https://<адрес сайта>/$l/" | grep -oE '<meta name="description" content="[^"]{0,80}'
92
+ done
93
+ ```
94
+
95
+ ## Частые промахи
96
+
97
+ - **Проверять результат в админке.** Форма показывает то, что в хранилище, и молчит о кэше: три
98
+ локали были записаны и проверены, а сайт ещё полчаса отдавал прежний текст на всех.
99
+ - **Считать, что перевода нет, раз пришло предупреждение.** Прежние локали остаются на месте;
100
+ пустой окажется только та, которой не было раньше.
101
+ - **Писать в хранилище напрямую.** Запись в боевое хранилище запрещена совсем, а колонка со
102
+ свободной структурой выглядит безобидной целью — тексты правятся через админку, и другого
103
+ пути у них нет.
104
+ - **Переводить имя собственное.** Оно остаётся как есть во всех локалях: так велит словарь в
105
+ задании провайдеру, и рукописный перевод обязан вести себя так же.
106
+ - **Копировать одно письмо языка в другое.** Упрощённое и традиционное письмо — разные локали и
107
+ разное письмо; посимвольная копия простояла на проде, и гость читал чужие иероглифы.
@@ -27,7 +27,7 @@ description: Паттерн правила translations. Брать, когда
27
27
  | `admin.json` | админ-панель |
28
28
  | `mail.json` | письма и документ-основание |
29
29
 
30
- Локалей восемь: `en`, `ru`, `de`, `zh-Hans`, `zh-Hant`, `ko`, `th`, `hi`. Ключ заводится во
30
+ Набор локалей перевода назван в `implementation.md` правила. Ключ заводится во
31
31
  всех — пустое значение считается пропуском, а не переводом.
32
32
 
33
33
  ## Подстановка
@@ -40,6 +40,11 @@ description: Правило под «Закон о фронтовом прило
40
40
  объявлена подписка.
41
41
  - **Источник действия носит суффикс `Source` в имени.** Иначе поток и значение в коде
42
42
  неотличимы, и `next` уходит не туда.
43
+ - **Вызов сервиса внутри `computed` зависимостью не становится.** Производное значение следит
44
+ только за прочитанными сигналами, а обычный метод сигналом не является: значение остаётся
45
+ таким, каким было в момент первого счёта. Текущий язык, текущее владение, текущий признак
46
+ среды читаются сигналом службы — тогда производное пересобирается вместе с их сменой. Ни
47
+ сборка, ни линтер этого не видят.
43
48
  - **Списочный стор наследует общую основу.** Записи, страница, порядок, условия отбора, строка
44
49
  поиска и конфиг выборки уже там, и наследнику остаются четыре строки.
45
50