@starci/hfs 5.4.0 → 5.5.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 (127) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/main.mjs +6 -1
  6. package/codemod/seeds-layout.d.mts +36 -0
  7. package/codemod/seeds-layout.mjs +113 -0
  8. package/codemod/sops-rules.d.mts +1 -1
  9. package/codemod/sops-rules.mjs +8 -3
  10. package/lint/run.d.mts +5 -4
  11. package/lint/run.mjs +53 -27
  12. package/package.json +1 -1
  13. package/release/lint-report.mjs +4 -3
  14. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  15. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  16. package/runtime/knowledge/hfs/rules.yaml +15 -2
  17. package/runtime/knowledge/hfs/slots.yaml +15 -18
  18. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  19. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  20. package/runtime/scripts/hfs/check.mjs +4 -1
  21. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  22. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  23. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  25. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  26. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  27. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  28. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  29. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  30. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  31. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  32. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  33. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  34. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  35. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  36. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  37. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  38. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  39. package/runtime/scripts/hfs/slots.d.mts +2 -0
  40. package/runtime/scripts/hfs/slots.mjs +1 -0
  41. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  42. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  43. package/runtime/scripts/hfs/view.d.mts +25 -0
  44. package/runtime/scripts/hfs/view.mjs +41 -0
  45. package/scaffold/add-app.mjs +2 -2
  46. package/scaffold/add-cli-lite.mjs +41 -14
  47. package/scaffold/add-module.mjs +6 -3
  48. package/scaffold/lite-exports.mjs +1 -1
  49. package/scaffold/module-registry.mjs +10 -8
  50. package/src/main.mjs +8 -8
  51. package/stack/deploy.d.mts +3 -0
  52. package/stack/deploy.mjs +4 -1
  53. package/stack/local-up.mjs +25 -3
  54. package/stack/seeds.d.mts +29 -0
  55. package/stack/seeds.mjs +197 -0
  56. package/sync/canon-layout.d.mts +38 -0
  57. package/sync/canon-layout.mjs +62 -0
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +10 -7
  60. package/sync/index.d.mts +13 -2
  61. package/sync/index.mjs +52 -24
  62. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  63. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  64. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  65. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  66. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  67. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  68. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  69. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  70. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  71. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  72. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  73. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  74. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  76. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  77. package/templates/app/skeleton/README.md +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  79. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  80. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  81. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  82. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  83. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  84. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  85. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  86. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  87. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/README.md +2 -2
  92. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  94. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  95. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  96. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  97. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  98. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  99. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  100. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  101. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  102. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  103. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  104. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  105. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  106. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  107. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  108. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +59 -9
  109. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
  110. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  111. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  112. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  113. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  114. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  115. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  116. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  117. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  118. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  119. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  120. package/templates/be/tool-config/jest.config.js +1 -1
  121. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  122. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  123. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  124. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  125. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  126. package/templates/app/module/jest.config.js.tpl +0 -8
  127. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -0,0 +1,239 @@
1
+ // stack-seeds-config.mjs - reads and judges a seed level's manifest (`seeds.yaml`, schema starci/seeds@1), the declaration of the data an app
2
+ // seeds (contexts/project/stacks.md PROJECT-STACKS-12). A level is `.starcistacks/seeds/` (shared, every environment, first) or
3
+ // `.starcistacks/<env>/seeds/` (that environment only); each holds the manifest and the folders `sql/`, `data/` and `files/`. One entry is
4
+ // `{ id, kind, path, target, mode, after?, schema?, reason? }`: a `sql` entry is idempotent DML the harness runs on the database `target`
5
+ // names, a `data` entry is a document (or a folder of them) an app command (`target`, an argv list) loads after it validates against the JSON
6
+ // schema `schema` names, a `test-account` entry is the one data entry of an environment's level that declares a test account (with its
7
+ // `reason`), a `files` entry is assets for the bucket `target` names. A document names a secret as `{ "secret": "seeds-<name>" }`, a sealed
8
+ // member of the environment (`.starcistacks/<env>/secrets/seeds-<name>.enc.yaml`) the seed step opens in memory, never a value. The same
9
+ // normaliser serves `starci app check` (R375) and the seed step of the stack verbs, so a manifest the check passes is one the step applies.
10
+ import crypto from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { parseYaml, stringifyYaml } from '../../engine/yaml.mjs';
14
+ import { closedFields, collector, patternField } from './stack-config-kit.mjs';
15
+ export const SEEDS_SCHEMA = 'starci/seeds@1';
16
+ /** The folder name of a seed level, at `.starcistacks/` and under each environment. */
17
+ export const SEEDS_DIR = 'seeds';
18
+ /** The manifest of a level. */
19
+ export const SEEDS_MANIFEST = 'seeds.yaml';
20
+ /** The kinds of entry; each lives in the folder `kindFolder` names. */
21
+ export const SEED_KINDS = Object.freeze(['sql', 'data', 'test-account', 'files']);
22
+ /** The folders of a level. */
23
+ export const SEED_FOLDERS = Object.freeze(['sql', 'data', 'files']);
24
+ /** `once`: applied a single time; `upsert`: applied again whenever its content hash changes. */
25
+ export const SEED_MODES = Object.freeze(['once', 'upsert']);
26
+ /** The placeholder that keeps an empty seed folder tracked. */
27
+ export const SEEDS_KEEP = '.gitkeep';
28
+ const ID = /^[a-z0-9][a-z0-9-]{0,62}$/u;
29
+ const NAME = /^[a-z][a-z0-9_-]{0,62}$/u;
30
+ const ENTRY_FIELDS = ['id', 'kind', 'path', 'target', 'mode', 'after', 'schema', 'reason'];
31
+ const DOCUMENT = /\.(?:json|ya?ml)$/u;
32
+ /** The name of a sealed member a seed document may reference. */
33
+ export const SEED_SECRET_MEMBER = /^seeds-[a-z0-9][a-z0-9-]{0,62}$/u;
34
+ /** The folder of a level a kind's paths live in: a test account is data. */
35
+ export const kindFolder = (kind) => (kind === 'test-account' ? 'data' : kind);
36
+ /** True for an entry the app command loads as documents: `data` and `test-account`. */
37
+ export const isDocumentEntry = (entry) => entry.kind === 'data' || entry.kind === 'test-account';
38
+ /** The sealed member file of `member` in the environment `env`, app-relative. */
39
+ export const seedMemberFile = (env, member) => `.starcistacks/${env}/secrets/${member}.enc.yaml`;
40
+ /** The app-relative folder of a level: the shared one when `env` is null. */
41
+ export const seedLevelDir = (env) => (env === null ? `.starcistacks/${SEEDS_DIR}` : `.starcistacks/${env}/${SEEDS_DIR}`);
42
+ const isFile = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isFile() === true;
43
+ const isFolder = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isDirectory() === true;
44
+ /** Every file under `folder` (absolute) as a path relative to `base`, posix and sorted, the placeholders aside; a missing folder holds none. */
45
+ export function filesBelow(base, folder) {
46
+ if (!isFolder(folder))
47
+ return [];
48
+ return fs.readdirSync(folder, { recursive: true, withFileTypes: true })
49
+ .filter((entry) => entry.isFile() && entry.name !== SEEDS_KEEP)
50
+ .map((entry) => path.relative(base, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'))
51
+ .sort((a, b) => (a < b ? -1 : Number(a > b)));
52
+ }
53
+ /** The level-relative files an entry covers: its path when it is a file, every file below it when it is a folder. */
54
+ export function entryFiles(levelRoot, entry) {
55
+ const target = path.join(levelRoot, entry.path);
56
+ return isFile(target) ? [entry.path] : filesBelow(levelRoot, target);
57
+ }
58
+ /** The content hash of an entry: its kind, target, mode and every file it covers, by path and bytes. */
59
+ export function entryDigest(levelRoot, entry) {
60
+ const hash = crypto.createHash('sha256').update(JSON.stringify([entry.kind, entry.target, entry.mode]));
61
+ for (const file of entryFiles(levelRoot, entry))
62
+ hash.update('\0').update(file).update('\0').update(fs.readFileSync(path.join(levelRoot, file)));
63
+ return hash.digest('hex');
64
+ }
65
+ function targetOf(c, at, kind, value) {
66
+ if (kind === 'data' || kind === 'test-account') {
67
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || item === ''))
68
+ c.problem(at, 'must be the argv list of the app command that loads the data (never a shell line)');
69
+ return Array.isArray(value) ? value.map(String) : [];
70
+ }
71
+ patternField(c, at, value, NAME);
72
+ return String(value ?? '');
73
+ }
74
+ function entryOf(c, at, value, levelRoot, ids) {
75
+ if (!closedFields(c, at, value, ENTRY_FIELDS, 'seed entry'))
76
+ return null;
77
+ const raw = value;
78
+ if (patternField(c, `${at}.id`, raw.id, ID) && ids.has(String(raw.id)))
79
+ c.problem(`${at}.id`, `${String(raw.id)} is declared twice`);
80
+ ids.add(String(raw.id));
81
+ const kind = SEED_KINDS.find((item) => item === raw.kind);
82
+ if (kind === undefined) {
83
+ c.problem(`${at}.kind`, `must be one of ${SEED_KINDS.join(', ')}`);
84
+ return null;
85
+ }
86
+ const file = typeof raw.path === 'string' ? raw.path : '';
87
+ const folder = kindFolder(kind);
88
+ if (!file.startsWith(`${folder}/`) || path.posix.normalize(file) !== file || file.includes('\\'))
89
+ c.problem(`${at}.path`, `must be a path under ${folder}/ of the level`);
90
+ else if (!fs.existsSync(path.join(levelRoot, file)))
91
+ c.problem(`${at}.path`, `${file} does not exist in the level`);
92
+ const mode = SEED_MODES.find((item) => item === raw.mode);
93
+ if (mode === undefined)
94
+ c.problem(`${at}.mode`, `must be one of ${SEED_MODES.join(', ')}`);
95
+ const after = raw.after === undefined ? [] : raw.after;
96
+ if (!Array.isArray(after) || after.some((item) => typeof item !== 'string'))
97
+ c.problem(`${at}.after`, 'must be a list of entry ids');
98
+ const documents = isDocumentEntry({ kind });
99
+ if (documents && (typeof raw.schema !== 'string' || raw.schema === ''))
100
+ c.problem(`${at}.schema`, 'must name the JSON schema the app declares for the data, app-root relative');
101
+ if (!documents && raw.schema !== undefined)
102
+ c.problem(`${at}.schema`, 'is a field of a data or test-account entry only');
103
+ if (kind === 'test-account' && (typeof raw.reason !== 'string' || raw.reason.trim() === ''))
104
+ c.problem(`${at}.reason`, 'must say why this environment holds a test account');
105
+ if (kind !== 'test-account' && raw.reason !== undefined)
106
+ c.problem(`${at}.reason`, 'is a field of a test-account entry only');
107
+ return {
108
+ id: String(raw.id), kind, path: file, target: targetOf(c, `${at}.target`, kind, raw.target), mode: mode ?? 'upsert',
109
+ after: Array.isArray(after) ? after.map(String) : [], schema: documents && typeof raw.schema === 'string' ? raw.schema : null,
110
+ reason: kind === 'test-account' && typeof raw.reason === 'string' ? raw.reason : null,
111
+ };
112
+ }
113
+ /** The entries in apply order: manifest order, each after the entries it names; an unknown id or a cycle is a problem. */
114
+ function applyOrder(c, entries, shared) {
115
+ const own = new Set(entries.map((entry) => entry.id));
116
+ for (const entry of entries)
117
+ for (const id of entry.after)
118
+ if (!own.has(id) && !shared.has(id))
119
+ c.problem(`entries.${entry.id}.after`, `${id} is no entry of this level or the shared level`);
120
+ const done = new Set();
121
+ const order = [];
122
+ let pending = entries;
123
+ while (pending.length > 0) {
124
+ const ready = pending.filter((entry) => entry.after.every((id) => !own.has(id) || done.has(id)));
125
+ if (ready.length === 0) {
126
+ c.problem('entries', `the after lists of ${pending.map((entry) => entry.id).join(', ')} form a cycle`);
127
+ return [...order, ...pending];
128
+ }
129
+ for (const entry of ready) {
130
+ order.push(entry);
131
+ done.add(entry.id);
132
+ }
133
+ pending = pending.filter((entry) => !done.has(entry.id));
134
+ }
135
+ return order;
136
+ }
137
+ /**
138
+ * Normalises the parsed manifest of the level at `levelRoot`; `shared` holds the ids of the shared level an environment's entries may follow,
139
+ * and is null for the shared level itself, which declares no test account (an environment's level declares at most one).
140
+ */
141
+ export function normalizeSeeds(doc, levelRoot, shared = new Set()) {
142
+ const c = collector();
143
+ if (!closedFields(c, 'manifest', doc, ['schema', 'entries'], 'seeds'))
144
+ return { entries: [], problems: c.problems };
145
+ const raw = doc;
146
+ if (raw.schema !== SEEDS_SCHEMA)
147
+ c.problem('schema', `must be ${SEEDS_SCHEMA}`);
148
+ const list = raw.entries ?? [];
149
+ if (!Array.isArray(list)) {
150
+ c.problem('entries', 'must be a list');
151
+ return { entries: [], problems: c.problems };
152
+ }
153
+ const ids = new Set();
154
+ const entries = list.flatMap((value, index) => entryOf(c, `entries[${index}]`, value, levelRoot, ids) ?? []);
155
+ const accounts = entries.filter((entry) => entry.kind === 'test-account').map((entry) => entry.id);
156
+ if (shared === null && accounts.length > 0)
157
+ c.problem('entries', `${accounts.join(', ')}: the shared level declares no test account; it belongs to the one environment that holds it`);
158
+ if (accounts.length > 1)
159
+ c.problem('entries', `${accounts.join(', ')}: a level declares at most one test account`);
160
+ return { entries: applyOrder(c, entries, shared ?? new Set()), problems: c.problems };
161
+ }
162
+ /** Reads and judges the level of `env` (null: the shared level) at `repoRoot`; a level without a manifest has no entries. */
163
+ export function readSeedLevel(repoRoot, env, shared = new Set()) {
164
+ const dir = seedLevelDir(env);
165
+ const file = path.join(repoRoot, dir, SEEDS_MANIFEST);
166
+ if (!isFile(file))
167
+ return { dir, env, entries: [], problems: [] };
168
+ let doc;
169
+ try {
170
+ doc = parseYaml(fs.readFileSync(file, 'utf8'));
171
+ }
172
+ catch (error) {
173
+ return { dir, env, entries: [], problems: [{ at: 'manifest', message: `does not parse: ${String(error).split('\n')[0]}` }] };
174
+ }
175
+ return { dir, env, ...normalizeSeeds(doc ?? {}, path.join(repoRoot, dir), env === null ? null : shared) };
176
+ }
177
+ /** The documents of a data or test-account entry: every JSON or YAML file it covers, parsed. */
178
+ export function seedDocuments(levelRoot, entry) {
179
+ return entryFiles(levelRoot, entry).filter((file) => DOCUMENT.test(file)).map((file) => {
180
+ const text = fs.readFileSync(path.join(levelRoot, file), 'utf8');
181
+ try {
182
+ return { path: file, doc: file.endsWith('.json') ? JSON.parse(text) : parseYaml(text) };
183
+ }
184
+ catch (error) {
185
+ return { path: file, error: String(error).split('\n')[0] ?? '' };
186
+ }
187
+ });
188
+ }
189
+ /** The member a value references when it is a secret reference (`{ "secret": "<member>" }` and nothing else), else null. */
190
+ export function secretReference(value) {
191
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
192
+ return null;
193
+ const keys = Object.keys(value);
194
+ const member = value.secret;
195
+ return keys.length === 1 && typeof member === 'string' ? member : null;
196
+ }
197
+ /** Every member the secret references of a document name, in document order, once each. */
198
+ export function secretReferences(doc) {
199
+ const found = [];
200
+ const visit = (value) => {
201
+ const member = secretReference(value);
202
+ if (member !== null) {
203
+ if (!found.includes(member))
204
+ found.push(member);
205
+ return;
206
+ }
207
+ if (value !== null && typeof value === 'object')
208
+ for (const item of Object.values(value))
209
+ visit(item);
210
+ };
211
+ visit(doc);
212
+ return found;
213
+ }
214
+ /** A copy of `doc` with every secret reference replaced by what `resolve` answers for its member. */
215
+ export function resolveSecrets(doc, resolve) {
216
+ const member = secretReference(doc);
217
+ if (member !== null)
218
+ return resolve(member);
219
+ if (Array.isArray(doc))
220
+ return doc.map((item) => resolveSecrets(item, resolve));
221
+ if (doc !== null && typeof doc === 'object')
222
+ return Object.fromEntries(Object.entries(doc).map(([key, item]) => [key, resolveSecrets(item, resolve)]));
223
+ return doc;
224
+ }
225
+ /**
226
+ * Declares `entries` in the manifest of the level of `env` (null: the shared level), creating the manifest when the level has none; an id the
227
+ * manifest already holds is kept as it is. Answers the ids it added (none are written when `write` is false).
228
+ */
229
+ export function addSeedEntries(repoRoot, env, entries, write = true) {
230
+ const file = path.join(repoRoot, seedLevelDir(env), SEEDS_MANIFEST);
231
+ const doc = (isFile(file) ? parseYaml(fs.readFileSync(file, 'utf8')) : null);
232
+ const held = Array.isArray(doc?.entries) ? doc.entries : [];
233
+ const added = entries.filter((entry) => !held.some((item) => item.id === entry.id));
234
+ if (added.length > 0 && write) {
235
+ fs.mkdirSync(path.dirname(file), { recursive: true });
236
+ fs.writeFileSync(file, stringifyYaml({ schema: SEEDS_SCHEMA, ...doc, entries: [...held, ...added] }));
237
+ }
238
+ return added.map((entry) => entry.id);
239
+ }
@@ -19,6 +19,22 @@ export interface HfsView {
19
19
  ownerOf: (file: string) => string | null;
20
20
  allows: (file: string) => AllowsResult | null;
21
21
  slot: OpenedHfs['slot'];
22
+ /** The vocabulary a root workspace package declares for itself (`starci` of its manifest); only the view of the root packages has it. */
23
+ packageContract?: (file: string) => PackageContract | null;
24
+ }
25
+ /** A type a root package declares: its name and the file of the package that declares it, relative to the app root. */
26
+ export interface PackageDeclaredType {
27
+ readonly name: string;
28
+ readonly file: string;
29
+ }
30
+ /**
31
+ * What a root workspace package declares in the `starci` key of its own `package.json`, since it cannot import the app's
32
+ * `platform/errors` or its `GraphQLResponse<T>`: `errorBase` is the class its errors derive from, `outcome` its one result union, each
33
+ * `{ name, file }` with `file` relative to the package folder.
34
+ */
35
+ export interface PackageContract {
36
+ readonly errorBase?: PackageDeclaredType;
37
+ readonly outcome?: PackageDeclaredType;
22
38
  }
23
39
  /**
24
40
  * The view of the app folder on disk at `repoRoot`, against the manifest under `runtimeRoot`: a side folder (be/, fe/) finds the
@@ -43,6 +59,15 @@ export declare const declaredHfsView: ({ runtimeRoot, declaration, repoRoot, sid
43
59
  repoRoot: string;
44
60
  side: string;
45
61
  }) => HfsView;
62
+ /**
63
+ * The contract of the package at `packageDir` (app-root relative, `packages/<pkg>`), read from the `starci` key of its manifest. A
64
+ * declaration that is not `{ name, file }` with an identifier name and a file inside `src/` stops the lint, because a rule must not guess.
65
+ *
66
+ * @param {string} repoRoot - The absolute app root.
67
+ * @param {string} packageDir - The package folder, relative to the app root.
68
+ * @returns {object} The declared error base and outcome union, each with its file relative to the app root.
69
+ */
70
+ export declare const readPackageContract: (repoRoot: string, packageDir: string) => PackageContract;
46
71
  /**
47
72
  * The view of the root packages of the app at `repoRoot` (the app root, where the root `eslint.config.mjs` lives), for the laws of `side`.
48
73
  *
@@ -2,6 +2,7 @@
2
2
  // their rules (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
3
3
  // runtime/ bundle (scripts/hfs/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
4
4
  // architecture machine and `starci app check` use, and never tests a path with a regular expression of its own.
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { allowsFile } from './allows.mjs';
7
8
  import { openHfs } from './slots.mjs';
@@ -70,6 +71,36 @@ export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root
70
71
  * @returns {object} The frozen view.
71
72
  */
72
73
  export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
74
+ const CONTRACT_KEYS = ['errorBase', 'outcome'];
75
+ /**
76
+ * The contract of the package at `packageDir` (app-root relative, `packages/<pkg>`), read from the `starci` key of its manifest. A
77
+ * declaration that is not `{ name, file }` with an identifier name and a file inside `src/` stops the lint, because a rule must not guess.
78
+ *
79
+ * @param {string} repoRoot - The absolute app root.
80
+ * @param {string} packageDir - The package folder, relative to the app root.
81
+ * @returns {object} The declared error base and outcome union, each with its file relative to the app root.
82
+ */
83
+ export const readPackageContract = (repoRoot, packageDir) => {
84
+ const manifestPath = path.join(repoRoot, packageDir, 'package.json');
85
+ const manifest = (fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : null);
86
+ const declared = manifest?.starci;
87
+ if (declared === undefined)
88
+ return {};
89
+ if (typeof declared !== 'object' || declared === null)
90
+ throw new Error(`${packageDir}/package.json: "starci" is an object ({ errorBase, outcome })`);
91
+ const contract = {};
92
+ for (const key of CONTRACT_KEYS) {
93
+ const entry = declared[key];
94
+ if (entry === undefined)
95
+ continue;
96
+ const { name, file } = (typeof entry === 'object' && entry !== null ? entry : {});
97
+ if (typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name) || typeof file !== 'string' || !/^src\/(?:[\w.-]+\/)*[\w.-]+\.ts$/.test(file)) {
98
+ throw new Error(`${packageDir}/package.json: "starci.${key}" is { "name": "<Identifier>", "file": "src/<path>.ts" }`);
99
+ }
100
+ contract[key] = Object.freeze({ name, file: `${packageDir}/${file}` });
101
+ }
102
+ return Object.freeze(contract);
103
+ };
73
104
  /**
74
105
  * The view of the root workspace packages of an app (`packages/<pkg>/src/**`, slots app.packages and app.module-registry), as the lint of one
75
106
  * side sees them: the rule parameters, the apps and the Grammar of `side` (a package is judged by the laws of the side whose subpath a file
@@ -103,6 +134,15 @@ function packagesView(opened, repoRoot, side) {
103
134
  const relative = rel(file);
104
135
  return slot !== undefined && ROOT_PACKAGE_SLOTS.has(slot) ? relative.split('/').slice(0, 2).join('/') : null;
105
136
  };
137
+ const contracts = new Map();
138
+ const packageContract = (file) => {
139
+ const dir = packageOf(file);
140
+ if (dir === null)
141
+ return null;
142
+ if (!contracts.has(dir))
143
+ contracts.set(dir, readPackageContract(repoRoot, dir));
144
+ return contracts.get(dir);
145
+ };
106
146
  return Object.freeze({
107
147
  repoRoot,
108
148
  profile: side,
@@ -121,6 +161,7 @@ function packagesView(opened, repoRoot, side) {
121
161
  ownerOf: () => null,
122
162
  allows: (file) => allowsFile(opened, rel(file)),
123
163
  slot: (id) => opened.slot(id),
164
+ packageContract,
124
165
  });
125
166
  }
126
167
  /**
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { loadSlotManifest, readRepoDeclaration, resolveAppDeclaration, } from "../runtime/scripts/hfs/slots.mjs";
4
- import { imageFiles, render, TEMPLATES_DIR } from "../sync/index.mjs";
4
+ import { appSource, imageFiles, render, TEMPLATES_DIR } from "../sync/index.mjs";
5
5
  import { ScaffoldError, pascalOf } from "./service.mjs";
6
6
  import { PARTIAL_VARIABLES, coreFeApps, jsonText, packageJsonText } from "./app.mjs";
7
7
  /** The shared fe app core (modules/api, auth, toast, redux, the providers, the hooks): the base tree, then the edition overlay. */
@@ -121,7 +121,7 @@ export function addApp({ root, name }) {
121
121
  const planned = [...planAppTemplates(repo, name), ...planWorkspaceManifests(root, repo, repo.sides.fe.apps, name)];
122
122
  const declaration = JSON.parse(fs.readFileSync(declarationFile, "utf8"));
123
123
  declaration.sides.fe.apps.push({ name, kind: "next" });
124
- const image = imageFiles(resolveAppDeclaration(manifest, declaration)).find((file) => file.path === `${targetBase}/Dockerfile`);
124
+ const image = imageFiles(resolveAppDeclaration(manifest, declaration), appSource(root)).find((file) => file.path === `${targetBase}/Dockerfile`);
125
125
  if (!image && repo.edition !== "lite")
126
126
  throw new ScaffoldError("HFS_ADD_TEMPLATE_MISSING", `the Dockerfile of front-end app ${name} did not render`);
127
127
  if (image)
@@ -43,19 +43,31 @@ export class RunCli extends CommandRunner {
43
43
  }
44
44
  }
45
45
  `;
46
- const SEED_RUN = `import { CommandRunner, SubCommand } from "nest-commander"
47
- import { SeedRunnerService } from "@modules/platform/database"
46
+ const SEED_RUN = `import { CommandRunner, Option, SubCommand } from "nest-commander"
47
+ import { DEFAULT_SEED_ENV, SeedRunnerService } from "@modules/platform/database"
48
+
49
+ /** The options of \`cli seed run\`. */
50
+ export interface SeedRunOptions {
51
+ /** The stack environment whose \`.starcistacks/<env>/seeds/sql\` runs after \`.starcistacks/seeds/sql\`. */
52
+ readonly env?: string
53
+ }
48
54
 
49
- @SubCommand({ name: "run", description: "Run the tracked seed through the application role" })
50
- /** \`cli seed run\`: executes the one tracked seed through the shared least-privilege EntityManager. */
55
+ @SubCommand({ name: "run", description: "Run the tracked SQL seeds through the application role" })
56
+ /** \`cli seed run [--env <name>]\`: executes the shared SQL seeds, then the environment's, through the shared least-privilege EntityManager. */
51
57
  export class RunSeedsCli extends CommandRunner {
52
58
  constructor(private readonly seeds: SeedRunnerService) {
53
59
  super()
54
60
  }
55
61
 
56
- /** Executes the tracked seed when it contains at least one statement. */
57
- async run(): Promise<void> {
58
- await this.seeds.run()
62
+ /** \`--env <name>\`: the stack environment whose seed runs after the shared one. */
63
+ @Option({ flags: "--env <name>", description: "The stack environment whose seed runs", defaultValue: DEFAULT_SEED_ENV })
64
+ parseEnv(value: string): string {
65
+ return value
66
+ }
67
+
68
+ /** Executes the shared seed and the environment's own. */
69
+ async run(_passed: Array<string>, options?: SeedRunOptions): Promise<void> {
70
+ await this.seeds.run(options?.env)
59
71
  }
60
72
  }
61
73
  `;
@@ -64,17 +76,32 @@ import { Test } from "@nestjs/testing"
64
76
  import { SeedRunnerService } from "@modules/platform/database"
65
77
  import { RunSeedsCli } from "./run.cli"
66
78
 
79
+ const build = async () => {
80
+ const seeds = mock<SeedRunnerService>()
81
+ const moduleRef = await Test.createTestingModule({
82
+ providers: [RunSeedsCli, { provide: SeedRunnerService, useValue: seeds }],
83
+ }).compile()
84
+ return { command: moduleRef.get(RunSeedsCli), seeds }
85
+ }
86
+
67
87
  describe("RunSeedsCli", () => {
68
- it("delegates the tracked seed run to the seed runner once", async () => {
69
- const seeds = mock<SeedRunnerService>()
70
- const moduleRef = await Test.createTestingModule({
71
- providers: [RunSeedsCli, { provide: SeedRunnerService, useValue: seeds }],
72
- }).compile()
88
+ it("delegates the seed run of the named environment to the seed runner once", async () => {
89
+ const { command, seeds } = await build()
73
90
 
74
- await expect(moduleRef.get(RunSeedsCli).run()).resolves.toBeUndefined()
91
+ await expect(command.run([], { env: "production" })).resolves.toBeUndefined()
75
92
 
93
+ expect(seeds.run).toHaveBeenCalledWith("production")
76
94
  expect(seeds.run).toHaveBeenCalledTimes(1)
77
95
  })
96
+
97
+ it("delegates with no environment when none is named, and takes --env as given", async () => {
98
+ const { command, seeds } = await build()
99
+
100
+ await command.run([])
101
+
102
+ expect(seeds.run).toHaveBeenCalledWith(undefined)
103
+ expect(command.parseEnv("production")).toBe("production")
104
+ })
78
105
  })
79
106
  `;
80
107
  const SEED_MODULE = `import { Module } from "@nestjs/common"
@@ -82,7 +109,7 @@ import { SeedCli } from "./seed.cli"
82
109
  import { RunSeedsCli } from "./subs/run.cli"
83
110
 
84
111
  @Module({ providers: [SeedCli, RunSeedsCli] })
85
- /** The lite seed group: the command and its one tracked seed runner. */
112
+ /** The lite seed group: the command and its runner of the tracked SQL seeds. */
86
113
  export class SeedModule {}
87
114
  `;
88
115
  function enableLiteCliDatabase(root) {
@@ -46,10 +46,11 @@ function manifests(project, name, versions) {
46
46
  private: true,
47
47
  description: `The ${pascalOf(name)} module on the ${project} agent SDK: its template (./template) and workbench declaration (./manifest), both React-free for the back end, its Grammar-only workbench (./workbench) and its public face (./public) for the front end.`,
48
48
  exports,
49
- scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false', test: 'jest --coverage' },
49
+ // No test script and no jest config of its own: its specs run in the back end's unit run (the root packages of be/jest.config.js, BE-TEST-13).
50
+ scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false' },
50
51
  dependencies: { [sdk]: '*' },
51
52
  peerDependencies: { '@starci/grammar': versions['@starci/grammar'], react: versions['react'] },
52
- devDependencies: sortedDependencies({ '@types/jest': versions['@types/jest'] ?? '', '@types/react': versions['@types/react'] ?? '', jest: versions['jest'] ?? '', 'ts-jest': versions['ts-jest'] ?? '', typescript: versions['typescript'] ?? '' }),
53
+ devDependencies: sortedDependencies({ '@types/jest': versions['@types/jest'] ?? '', '@types/react': versions['@types/react'] ?? '', typescript: versions['typescript'] ?? '' }),
53
54
  };
54
55
  const build = { rootDir: 'src', noEmit: false, declaration: true, types: [] };
55
56
  return [
@@ -65,7 +66,7 @@ function versionsOf(root, feAppFiles) {
65
66
  const all = { ...rootPackage.devDependencies, ...rootPackage.dependencies };
66
67
  for (const file of feAppFiles)
67
68
  Object.assign(all, read(file).dependencies);
68
- const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'jest', 'ts-jest', 'typescript'];
69
+ const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'typescript'];
69
70
  const missing = wanted.filter((name) => all[name] === undefined);
70
71
  if (missing.length > 0)
71
72
  throw new ScaffoldError('HFS_ADD_MODULE_PINS', `add module reads the versions of ${missing.join(', ')} from the app's manifests, which declare none of them`);
@@ -117,6 +118,8 @@ function context(root, name) {
117
118
  if (!fs.existsSync(declarationFile) || !fs.existsSync(packageFile))
118
119
  throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'a module verb runs at the app root containing hfs.json and package.json');
119
120
  const repo = readRepoDeclaration(loadSlotManifest(), root);
121
+ if (repo.modules === 'data')
122
+ throw new ScaffoldError('HFS_MODULES_ARE_DATA', 'hfs.json declares modules: "data": a module of this app is a seed under .starcistacks/seeds/data/modules, not a package, so no module verb applies');
120
123
  return { project: repo.project, declarationFile, packageFile };
121
124
  }
122
125
  /** Sets or deletes one dependency of a manifest file, keeping the section sorted; answers whether the text changed. */
@@ -173,7 +173,7 @@ const REGISTRATIONS = Object.freeze({
173
173
  {
174
174
  file: "be/src/modules/platform/database/index.ts",
175
175
  source: "./seed-runner.service",
176
- names: ["SeedRunnerService"],
176
+ names: ["DEFAULT_SEED_ENV", "SeedRunnerService"],
177
177
  beforeSource: "./database.options",
178
178
  },
179
179
  ],
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { byCodeUnit } from '../report/order.mjs';
8
+ import { canonImport, canonJson, canonList } from '../sync/canon-layout.mjs';
8
9
  import { pascalOf } from './service.mjs';
9
10
  /** The folder name prefix of a module package. */
10
11
  export const MODULE_PREFIX = 'module-';
@@ -13,7 +14,8 @@ export const REGISTRY_DIR = 'packages/modules';
13
14
  /** The product's agent SDK package folder every module builds on. */
14
15
  export const SDK_DIR = 'packages/agent-sdk';
15
16
  const HEADER = '// Generated by starci app sync (module registry). Do not edit: run "npx starci app sync --execute".';
16
- const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
17
+ // A package.json is printed by Prettier the way JSON.stringify prints it; every other JSON file of the registry follows the canon layout.
18
+ const packageJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
17
19
  /** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
18
20
  export const upperOf = (name) => name.replace(/-/g, '_').toUpperCase();
19
21
  /** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
@@ -39,13 +41,13 @@ export function registryFiles(project, names) {
39
41
  const sorted = [...names].sort(byCodeUnit);
40
42
  const sdk = `@${project}/agent-sdk`;
41
43
  const module = (name) => `@${project}/${MODULE_PREFIX}${name}`;
42
- const serverImports = sorted.map((name) => `import { ${upperOf(name)}_SERVER } from "${module(name)}/template"`).join('\n');
43
- const workbenchImports = sorted.map((name) => `import { ${upperOf(name)}_WORKBENCH } from "${module(name)}/manifest"`).join('\n');
44
+ const serverImports = sorted.map((name) => canonImport({ names: [`${upperOf(name)}_SERVER`], from: `${module(name)}/template` })).join('\n');
45
+ const workbenchImports = sorted.map((name) => canonImport({ names: [`${upperOf(name)}_WORKBENCH`], from: `${module(name)}/manifest` })).join('\n');
44
46
  const dependencies = Object.fromEntries([[sdk, '*'], ...sorted.map((name) => [module(name), '*'])].sort(([a], [b]) => byCodeUnit(a, b)));
45
47
  return [
46
48
  {
47
49
  path: `${REGISTRY_DIR}/package.json`,
48
- content: json({
50
+ content: packageJson({
49
51
  name: `@${project}/modules`,
50
52
  version: '0.0.0',
51
53
  private: true,
@@ -57,11 +59,11 @@ export function registryFiles(project, names) {
57
59
  },
58
60
  {
59
61
  path: `${REGISTRY_DIR}/tsconfig.json`,
60
- content: json({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: [] }, include: ['src/**/*.ts'], exclude: ['node_modules', 'dist'] }),
62
+ content: canonJson({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: [] }, include: ['src/**/*.ts'], exclude: ['node_modules', 'dist'] }),
61
63
  },
62
64
  {
63
65
  path: `${REGISTRY_DIR}/tsconfig.build.json`,
64
- content: json({
66
+ content: canonJson({
65
67
  extends: './tsconfig.json',
66
68
  compilerOptions: { rootDir: 'src', outDir: 'dist', noEmit: false, declaration: true, module: 'node16', moduleResolution: 'node16' },
67
69
  }),
@@ -72,11 +74,11 @@ export function registryFiles(project, names) {
72
74
  },
73
75
  {
74
76
  path: `${REGISTRY_DIR}/src/template/index.ts`,
75
- content: `${HEADER}\nimport type { ModuleServer } from "${sdk}/module"\n${serverImports}\n\n/** The server half of every module built on the SDK; the back end reads nothing else of a module. */\nexport const MODULE_SERVERS: ReadonlyArray<ModuleServer> = [${sorted.map((name) => `${upperOf(name)}_SERVER`).join(', ')}]\n`,
77
+ content: `${HEADER}\n${canonImport({ names: ['ModuleServer'], from: `${sdk}/module`, type: true })}\n${serverImports}\n\n/** The server half of every module built on the SDK; the back end reads nothing else of a module. */\n${canonList({ head: 'export const MODULE_SERVERS: ReadonlyArray<ModuleServer> = ', items: sorted.map((name) => `${upperOf(name)}_SERVER`) })}\n`,
76
78
  },
77
79
  {
78
80
  path: `${REGISTRY_DIR}/src/manifest/index.ts`,
79
- content: `${HEADER}\nimport { specRegistryFrom } from "${sdk}/workbench/contract"\nimport type { WorkbenchDeclaration, WorkbenchSpecRegistry } from "${sdk}/workbench/contract"\n${workbenchImports}\n\n/** The workbench declaration of every module built on the SDK; the front end pairs each with how it draws. */\nexport const MODULE_WORKBENCHES: ReadonlyArray<WorkbenchDeclaration> = [${sorted.map((name) => `${upperOf(name)}_WORKBENCH`).join(', ')}]\n\n/** Everything a stored spec may name: the SDK's blocks and each module's exposed blocks, reads and actions. */\nexport const SPEC_REGISTRY: WorkbenchSpecRegistry = specRegistryFrom(MODULE_WORKBENCHES)\n`,
81
+ content: `${HEADER}\n${canonImport({ names: ['specRegistryFrom'], from: `${sdk}/workbench/contract` })}\n${canonImport({ names: ['WorkbenchDeclaration', 'WorkbenchSpecRegistry'], from: `${sdk}/workbench/contract`, type: true })}\n${workbenchImports}\n\n/** The workbench declaration of every module built on the SDK; the front end pairs each with how it draws. */\n${canonList({ head: 'export const MODULE_WORKBENCHES: ReadonlyArray<WorkbenchDeclaration> = ', items: sorted.map((name) => `${upperOf(name)}_WORKBENCH`) })}\n\n/** Everything a stored spec may name: the SDK's blocks and each module's exposed blocks, reads and actions. */\nexport const SPEC_REGISTRY: WorkbenchSpecRegistry = specRegistryFrom(MODULE_WORKBENCHES)\n`,
80
82
  },
81
83
  ];
82
84
  }
package/src/main.mjs CHANGED
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // The @starci/hfs implementation behind `starci app`: one repository, `<app>/`, with one package.json, lockfile and node_modules
10
10
  // at its root and two sides, be/ and fe/, declared by the one hfs.json of kind app. Every verb runs at the app root.
11
- // starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--side be|fe] [--fix] [--out <sonar file>] [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
11
+ // starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--side be|fe|packages] [--fix] [--out <sonar file>] [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
12
12
  // every tracked path has a slot; required files exist; nothing forbidden or
13
13
  // tracked-that-must-be-ignored; pins match; every managed file equals its render
14
14
  // (sync/managed.mjs, the .gitignore block and sonar-project.properties included); no empty or ghost
@@ -29,7 +29,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
29
29
  // progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
30
30
  // on any difference; =full never reads the cache (the release gate).
31
31
  // --coverage-from <lcov> judges a coverage report produced elsewhere (the merge of a sharded unit run) and records nothing.
32
- // --side be|fe limits the lint gate to one side (a CI matrix runs one per side). --out <file> writes every finding of the run as ONE
32
+ // --side be|fe|packages limits the lint gate to one side or to the root packages (a CI matrix runs one per scope). --out <file> writes every finding of the run as ONE
33
33
  // Sonar Generic Issue Import document (engine ids starci-hfs, eslint, stylelint). --fix runs ESLint and stylelint with --fix before the
34
34
  // check and is refused unless --gate lint is selected.
35
35
  // --staged: the pre-commit guard alone (sync/hygiene.mjs: staged .starciwork and .starcistacks paths, the secrets
@@ -104,7 +104,7 @@ import { addKind } from '../scaffold/add.mjs';
104
104
  import { removeModule } from '../scaffold/add-module.mjs';
105
105
  import { scaffoldApp } from '../scaffold/app.mjs';
106
106
  import { newImages } from '../scaffold/image.mjs';
107
- import { cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
107
+ import { PACKAGES, cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
108
108
  import { runSonarForApp, runTestForApp, runTestShard, runTypesForApp } from '../cache/app-families.mjs';
109
109
  import { CACHE_ENV, createDiskSession } from '../cache/session.mjs';
110
110
  import { DEFAULT_FAMILIES, runAppFamilies } from '../cache/run.mjs';
@@ -177,7 +177,7 @@ const PARSED_FLAGS = new Set([...Object.values(PARSED).flatMap(flagsOf), '--edit
177
177
  /** The placeholder of a value flag in the usage; any other prints as <name>. */
178
178
  const PLACEHOLDERS = {
179
179
  '--gate': `<${CHECK_GATES.join('|')}>`,
180
- '--registry-visibility': 'public|private', '--side': 'be|fe', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
180
+ '--registry-visibility': 'public|private', '--side': 'be|fe|packages', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
181
181
  '--event': '<event>', '--from': '<service>', '--service': '<Class>=<module>', '--owner': '<service>', '--failed': '<event>', '--done': '<event>',
182
182
  '--shapes': '<a,b,c>', '--blocks': '<a,b>', '--tabs': '<a,b>', '--kind': 'modal|drawer', '--image': '<service>=<ref>', '--sha': '<sha>',
183
183
  '--previous': '<release>', '--release': '<release>', '--out': '<path>', '--since': '<ISO>', '--timeout': '<seconds>', '--retry': '<seconds>',
@@ -478,7 +478,7 @@ function familyOf(finding) {
478
478
  async function fixLint({ repoRoot, side, run }) {
479
479
  const app = readRepoDeclaration(loadSlotManifest(), repoRoot);
480
480
  await eslintRepository({ repoRoot, app, opts: { ...(side === undefined ? {} : { side }), fix: true }, run });
481
- if (side !== 'be')
481
+ if (side === undefined || side === 'fe')
482
482
  await (run ?? nativeLinter)({ cwd: path.join(repoRoot, 'fe'), pkg: 'stylelint', bin: 'stylelint', args: [STYLE_GLOB, '--formatter', 'json', '--fix'] });
483
483
  }
484
484
  /** One run of the selected families against the cache, then the coverage proof on top; returns the result, what it took and the raw linter results. */
@@ -543,7 +543,7 @@ function writeSonarImport({ repoRoot, file, findings, raw, side }) {
543
543
  const onSide = (candidate) => {
544
544
  if (side === undefined)
545
545
  return true;
546
- const owner = SIDES.find((each) => candidate === each || candidate.startsWith(`${each}/`));
546
+ const owner = [...SIDES, PACKAGES].find((each) => candidate === each || candidate.startsWith(`${each}/`));
547
547
  return (owner ?? 'be') === side;
548
548
  };
549
549
  const sourceRoots = sourceRootsOf(properties).filter(onSide);
@@ -591,8 +591,8 @@ async function checkMain(opts, io) {
591
591
  const sonarOut = valueFlag(opts, 'out');
592
592
  const fix = booleanFlag(opts, 'fix') === true;
593
593
  const explicit = selectedFamilies(opts, []);
594
- if (side !== undefined && !['be', 'fe'].includes(side))
595
- throw new UsageError('LINT_SIDE_INVALID: --side is be or fe');
594
+ if (side !== undefined && !['be', 'fe', PACKAGES].includes(side))
595
+ throw new UsageError('LINT_SIDE_INVALID: --side is be, fe or packages');
596
596
  if (fix && !explicit.has('lint'))
597
597
  throw new UsageError('--fix runs the linters and needs --gate lint');
598
598
  if (shard !== undefined) {