@starci/hfs 4.7.23 → 4.7.24

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 (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/release/candidate-ci.mjs +20 -10
  5. package/release/gate.mjs +37 -19
  6. package/release/push.mjs +1 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  8. package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
  9. package/runtime/knowledge/hfs/rules.yaml +17 -17
  10. package/runtime/knowledge/hfs/slots.yaml +20 -1
  11. package/runtime/knowledge/patterns/be/saga.yaml +1 -1
  12. package/runtime/knowledge/sonar-gate.yaml +12 -12
  13. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
  15. package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
  16. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
  18. package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
  19. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
  20. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
  22. package/runtime/scripts/hfs/slots.mjs +3 -2
  23. package/runtime/scripts/lib/gate-loosening.mjs +248 -0
  24. package/runtime/scripts/lib/managed-json.mjs +51 -0
  25. package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
  26. package/stack/prepare.mjs +10 -3
  27. package/stack/release-store.mjs +3 -3
  28. package/stack/remote.mjs +9 -0
  29. package/sync/ci-commands.mjs +38 -90
  30. package/sync/index.mjs +10 -5
  31. package/templates/app/browser-config/browser/tsconfig.json +11 -0
  32. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
  33. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
  34. package/templates/app/hooks/husky/pre-push +4 -2
  35. package/templates/app/package-scripts/package.json +0 -1
  36. package/templates/app/package-scripts-lite/package.json +0 -1
  37. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
  40. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
  41. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
  42. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
  43. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
  44. package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
  45. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
  46. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
  47. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  48. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
  49. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
  50. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
  51. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
  52. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
  53. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
  54. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
  55. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
  56. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
  57. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
  58. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
  59. package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
  60. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
  61. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
  62. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
  63. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
  64. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
  65. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
  66. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
  67. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
  68. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
  69. package/templates/fe/auth/supabase-client.ts.tpl +7 -7
  70. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
  71. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
  72. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
  73. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
  74. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
  75. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
  76. package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
  77. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
  78. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
  79. package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
  80. package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
  81. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
  82. package/templates/fe/transport/graphql/clients/response.ts +1 -1
@@ -0,0 +1,248 @@
1
+ // gate-loosening.mjs — what "loosens a gate" means, mechanically (modules/kernel/gate-loosening.yaml), over the unified diff of one change.
2
+ // A loosening is one of:
3
+ // check-removed a list item removed from a gate file and not added back in the same file (an item that carries an id is the
4
+ // same item while an added item carries that id: a row whose fields change stays a check)
5
+ // check-narrowed an enforcer row `{kind, id, editions}` that keeps its kind and id but now runs in fewer editions (a lost restriction or
6
+ // a superset of editions is a widening, never a loosening)
7
+ // threshold-lowered a number of a gate file moved the loose way: a floor lowered, a ceiling raised
8
+ // allowlist-added a list item added to an allowlist file
9
+ // spec-deleted a spec file deleted in a change that also changes product code
10
+ // assertion-weakened a spec that loses more assertions than it gains in a change that also changes product code
11
+ // spec-skipped a skip marker added to a spec in a change that also changes product code
12
+ // A change that renames paths (a git rename, `git diff -M`) may MOVE the entries that name them: a removed line and an added line of the
13
+ // same file that are equal once every renamed prefix of the removed line is rewritten to its new name are one entry moved, not a check
14
+ // removed and an exception added (renamedPrefixesOf, movedLinesOf). A moved entry that also changes in any other way (a widened glob, an
15
+ // extra path) is still judged as a removal and an addition.
16
+ // The finding id is `<kind>:<file>:<detail>`. A loosening is always red (owner ruling loosening-always-red, 2026-10-22): there is no
17
+ // approval that lets one through, so a change that loosens a gate is fixed by restoring the gate (looseningStep).
18
+ // Only what a release shipped can be loosened (loosensReleased): a check, a spec or an assertion added after the release and folded
19
+ // away before the next one never was a gate of a release.
20
+ import { byCodeUnit } from './list.mjs';
21
+
22
+ const LIST_ITEM = /^\s*-\s+\S/;
23
+ const NUMBER_LINE = /^\s*(?:-\s+)?([A-Za-z][\w.-]*):\s*(-?\d+(?:\.\d+)?)\s*(?:#.*)?$/;
24
+
25
+ /** The files of a unified diff: [{file, deleted, added[], removed[]}] (changed lines without their marker). */
26
+ export function parseDiff(text) {
27
+ const files = [];
28
+ let current = null;
29
+ for (const line of String(text ?? '').split(/\r?\n/)) {
30
+ const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
31
+ if (header) { current = { file: header[2], deleted: false, added: [], removed: [] }; files.push(current); continue; }
32
+ if (!current) continue;
33
+ if (line.startsWith('deleted file mode')) current.deleted = true;
34
+ else if (line.startsWith('+') && !line.startsWith('+++')) current.added.push(line.slice(1));
35
+ else if (line.startsWith('-') && !line.startsWith('---')) current.removed.push(line.slice(1));
36
+ }
37
+ return files;
38
+ }
39
+
40
+ /** The rename lines `R<score> <old> <new>` (tab separated) of `git diff --name-status -M`: [{from, to}]. */
41
+ const renamesOf = (nameStatus) => String(nameStatus ?? '').split(/\r?\n/).map((line) => line.split('\t'))
42
+ .filter((fields) => /^R\d*$/.test(fields[0] ?? '') && fields.length === 3).map(([, from, to]) => ({ from, to }));
43
+
44
+ /** The moved prefix of one renamed file: its old and new path without the trailing segments they share. */
45
+ function movedPrefixOf({ from, to }) {
46
+ const before = from.split('/');
47
+ const after = to.split('/');
48
+ while (before.length > 1 && after.length > 1 && before.at(-1) === after.at(-1)) { before.pop(); after.pop(); }
49
+ return { from: before.join('/'), to: after.join('/') };
50
+ }
51
+
52
+ /**
53
+ * The path prefixes a change renamed, from its `git diff --name-status -M` output: [{from, to}], longest `from` first. A prefix is kept only
54
+ * when every renamed file under it moved to the same new prefix; an old prefix that went two ways names no single move and is dropped.
55
+ */
56
+ export function renamedPrefixesOf(nameStatus) {
57
+ const targets = new Map();
58
+ for (const { from, to } of renamesOf(nameStatus).map(movedPrefixOf)) {
59
+ if (from === to) continue;
60
+ if (!targets.has(from)) targets.set(from, new Set());
61
+ targets.get(from).add(to);
62
+ }
63
+ return [...targets].filter(([, to]) => to.size === 1).map(([from, to]) => ({ from, to: [...to][0] }))
64
+ .sort((a, b) => b.from.length - a.from.length || byCodeUnit(a.from, b.from));
65
+ }
66
+
67
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
68
+
69
+ /** `line` with every renamed prefix rewritten to its new name; a prefix matches only as a whole path (not inside a longer name). */
70
+ function renamedLine(line, prefixes) {
71
+ return prefixes.reduce((text, { from, to }) => text.replace(new RegExp(String.raw`(?<![\w./-])${escapeRegExp(from)}(?![\w.-])`, 'g'), to), line);
72
+ }
73
+
74
+ /**
75
+ * One file of a change without its moved entries: each removed line whose renamed form is exactly an added line is paired with it, and
76
+ * both are left out. A line the rename does not touch is never paired, so an entry merely removed or added stays.
77
+ */
78
+ export function movedLinesOf(entry, prefixes) {
79
+ if (!prefixes.length) return entry;
80
+ const unpaired = new Map();
81
+ for (const line of entry.added) unpaired.set(line, (unpaired.get(line) ?? 0) + 1);
82
+ const paired = new Map();
83
+ const removed = entry.removed.filter((line) => {
84
+ const moved = renamedLine(line, prefixes);
85
+ if (moved === line || (unpaired.get(moved) ?? 0) <= 0) return true;
86
+ unpaired.set(moved, unpaired.get(moved) - 1);
87
+ paired.set(moved, (paired.get(moved) ?? 0) + 1);
88
+ return false;
89
+ });
90
+ const added = entry.added.filter((line) => {
91
+ if ((paired.get(line) ?? 0) <= 0) return true;
92
+ paired.set(line, paired.get(line) - 1);
93
+ return false;
94
+ });
95
+ return { ...entry, added, removed };
96
+ }
97
+
98
+ const keyWords = (key) => key.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
99
+ const trimmed = (line) => line.trim();
100
+ const clip = (text) => (text.length > 90 ? `${text.slice(0, 89)}…` : text);
101
+
102
+ /** What makes a list item the same item: its id when it carries one, else its text. */
103
+ function identityOf(line) {
104
+ const item = trimmed(line).slice(1).trimStart();
105
+ const body = (item.startsWith('{') ? item.slice(1) : item).trimStart();
106
+ if (!body.startsWith('id:')) return trimmed(line);
107
+ const id = body.slice(3).trimStart().split(/[,}\s]/)[0];
108
+ return id ? `id:${id}` : trimmed(line);
109
+ }
110
+
111
+ /** Splits `a: 1, b: [x, y]` at the commas outside square brackets. */
112
+ function splitTopLevel(text) {
113
+ const parts = [];
114
+ let depth = 0;
115
+ let from = 0;
116
+ for (let at = 0; at < text.length; at += 1) {
117
+ if (text[at] === '[') depth += 1;
118
+ else if (text[at] === ']') depth -= 1;
119
+ else if (text[at] === ',' && depth === 0) { parts.push(text.slice(from, at)); from = at + 1; }
120
+ }
121
+ parts.push(text.slice(from));
122
+ return parts;
123
+ }
124
+
125
+ /** The flow-mapping enforcer row `- {kind: k, id: i, editions: [a, b]}`: {kind, id, editions} (editions null = every edition), or null. */
126
+ function flowRow(line) {
127
+ const text = line.trim();
128
+ if (!text.startsWith('-')) return null;
129
+ const open = text.indexOf('{');
130
+ const close = text.lastIndexOf('}');
131
+ if (open < 1 || close < open || text.slice(1, open).trim() !== '') return null;
132
+ const fields = new Map();
133
+ for (const part of splitTopLevel(text.slice(open + 1, close))) {
134
+ const at = part.indexOf(':');
135
+ if (at > 0) fields.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
136
+ }
137
+ if (!fields.has('kind') || !fields.has('id')) return null;
138
+ const editions = fields.get('editions');
139
+ const list = editions === undefined ? null : editions.replace('[', '').replace(']', '').split(',').map((value) => value.trim()).filter(Boolean);
140
+ return { kind: fields.get('kind'), id: fields.get('id'), editions: list };
141
+ }
142
+
143
+ /** Whether row `after` still runs everywhere row `before` ran: a lost restriction or a superset of editions widens, a subset narrows. */
144
+ const coversEditions = (before, after) => after.editions === null || (before.editions !== null && before.editions.every((edition) => after.editions.includes(edition)));
145
+
146
+ /** The removed list items of a gate file that no added line restores; an enforcer row that keeps its kind and id may only widen its editions. */
147
+ function checksRemoved(entry) {
148
+ const back = new Map();
149
+ for (const line of entry.added.filter((value) => LIST_ITEM.test(value))) back.set(identityOf(line), (back.get(identityOf(line)) ?? 0) + 1);
150
+ const addedRows = entry.added.map(flowRow).filter(Boolean);
151
+ return entry.removed.filter((value) => LIST_ITEM.test(value)).flatMap((value) => {
152
+ const row = flowRow(value);
153
+ if (row) {
154
+ const same = addedRows.filter((candidate) => candidate.kind === row.kind && candidate.id === row.id);
155
+ if (same.length) {
156
+ if (same.some((candidate) => coversEditions(row, candidate))) return [];
157
+ return [{ kind: 'check-narrowed', file: entry.file, detail: clip(trimmed(value)) }];
158
+ }
159
+ }
160
+ const left = back.get(identityOf(value)) ?? 0;
161
+ if (left > 0) { back.set(identityOf(value), left - 1); return []; }
162
+ return [{ kind: 'check-removed', file: entry.file, detail: clip(trimmed(value)) }];
163
+ });
164
+ }
165
+
166
+ const numbersOf = (lines) => lines.map((line) => NUMBER_LINE.exec(line)).filter(Boolean).map((match) => ({ key: match[1], value: Number(match[2]) }));
167
+
168
+ /** The thresholds of a gate file moved the loose way. */
169
+ function thresholdsLowered(entry, rules) {
170
+ const added = numbersOf(entry.added);
171
+ return numbersOf(entry.removed).flatMap((before) => {
172
+ const after = added.find((candidate) => candidate.key === before.key);
173
+ if (!after || after.value === before.value) return [];
174
+ const words = keyWords(before.key);
175
+ const lowered = words.some((word) => rules.thresholds.floors.includes(word)) && after.value < before.value;
176
+ const raised = words.some((word) => rules.thresholds.ceilings.includes(word)) && after.value > before.value;
177
+ return lowered || raised ? [{ kind: 'threshold-lowered', file: entry.file, detail: `${before.key} ${before.value} -> ${after.value}` }] : [];
178
+ });
179
+ }
180
+
181
+ const SPEC_NAME = /\.spec\.[cm]?[jt]sx?$/;
182
+ const isSpec = (file, rules) => rules.specRoots.some((root) => file.startsWith(root)) && SPEC_NAME.test(file);
183
+ const countOf = (lines, markers) => lines.filter((line) => markers.some((marker) => line.includes(marker))).length;
184
+
185
+ const baseName = (file) => file.slice(file.lastIndexOf('/') + 1);
186
+
187
+ /** Whether a spec of the same name is written elsewhere in the change: a moved spec is not a deleted one. */
188
+ const movedSpec = (entry, files, rules) => files.some((other) => other !== entry && !other.deleted && isSpec(other.file, rules) && baseName(other.file) === baseName(entry.file));
189
+
190
+ /** The spec findings of one file of a change that also changes product code. */
191
+ function specFindings(entry, files, rules) {
192
+ if (entry.deleted) return movedSpec(entry, files, rules) ? [] : [{ kind: 'spec-deleted', file: entry.file, detail: 'deleted' }];
193
+ const weaker = countOf(entry.removed, rules.assertionMarkers) - countOf(entry.added, rules.assertionMarkers);
194
+ const skipped = countOf(entry.added, rules.skipMarkers) - countOf(entry.removed, rules.skipMarkers);
195
+ return [...(weaker > 0 ? [{ kind: 'assertion-weakened', file: entry.file, detail: `${weaker} assertion(s) fewer` }] : []),
196
+ ...(skipped > 0 ? [{ kind: 'spec-skipped', file: entry.file, detail: `${skipped} skip marker(s) added` }] : [])];
197
+ }
198
+
199
+ const changesProduct = (files, rules) => files.some((entry) => !isSpec(entry.file, rules) && rules.productPrefixes.some((prefix) => entry.file.startsWith(prefix)));
200
+
201
+ /**
202
+ * Every loosening of the change `diffText`, sorted: [{id, kind, file, detail}]. `rules` is modules/kernel/gate-loosening.yaml; `renamed`
203
+ * is the change's renamed prefixes (renamedPrefixesOf), whose moved entries are no loosening.
204
+ */
205
+ export function looseningsOf(diffText, rules, { renamed = [] } = {}) {
206
+ const files = parseDiff(diffText).map((entry) => movedLinesOf(entry, renamed));
207
+ const product = changesProduct(files, rules);
208
+ const found = files.flatMap((entry) => [
209
+ ...(rules.gateFiles.includes(entry.file) ? [...checksRemoved(entry), ...thresholdsLowered(entry, rules)] : []),
210
+ ...(rules.allowlistFiles.includes(entry.file) ? entry.added.filter((value) => LIST_ITEM.test(value)).map((value) => ({ kind: 'allowlist-added', file: entry.file, detail: clip(trimmed(value)) })) : []),
211
+ ...(product && isSpec(entry.file, rules) ? specFindings(entry, files, rules) : []),
212
+ ]);
213
+ return found.map((finding) => ({ ...finding, id: `${finding.kind}:${finding.file}:${finding.detail}` })).sort((a, b) => byCodeUnit(a.id, b.id));
214
+ }
215
+
216
+ /** One finding as the land gate and the self-check print it. */
217
+ export const looseningLine = (finding) => `${finding.kind} ${finding.file}: ${finding.detail}`;
218
+
219
+ /**
220
+ * The land gate's step for a change that loosens a gate: always red, with the way out that exists, restoring the gate. There is no
221
+ * approval: a loosening never lands (owner ruling loosening-always-red).
222
+ */
223
+ export const looseningStep = (findings) => ({
224
+ name: 'gate-loosening', ok: false, findings, output: findings.map(looseningLine).join('\n'),
225
+ hint: 'a change that loosens a gate never lands (owner ruling loosening-always-red): restore every gate listed above',
226
+ });
227
+
228
+ const assertionsIn = (text, markers) => String(text ?? '').split('\n').filter((line) => markers.some((marker) => line.includes(marker))).length;
229
+
230
+ /** The detail of a check-removed finding without its clipping mark: the start of the removed line. */
231
+ const shownDetail = (detail) => (detail.endsWith('…') ? detail.slice(0, -1) : detail);
232
+
233
+ /**
234
+ * Whether `finding` loosens the state released at `base`, read through `read(rev, file)` (text, or null when the revision holds no such
235
+ * file); `rules` gives the assertion markers. A finding stays only when
236
+ * check-removed the removed list item is an item of that gate file at the release commit
237
+ * spec-deleted the spec exists at the release commit
238
+ * spec-skipped the spec exists at the release commit
239
+ * assertion-weakened the spec exists at the release commit and holds fewer assertion lines now than it held there
240
+ * (every other kind loosens whenever its file existed at the release).
241
+ */
242
+ export function loosensReleased(finding, { base, read, rules }) {
243
+ const before = read(base, finding.file);
244
+ if (before === null) return false;
245
+ if (finding.kind === 'check-removed') return before.split('\n').some((line) => line.trim().startsWith(shownDetail(finding.detail)));
246
+ if (finding.kind !== 'assertion-weakened') return true;
247
+ return assertionsIn(read('HEAD', finding.file), rules.assertionMarkers) < assertionsIn(before, rules.assertionMarkers);
248
+ }
@@ -0,0 +1,51 @@
1
+ // managed-json.mjs - which JSON files are managed canon rather than authored JSON (knowledge/hfs/slots.yaml `managedBy` /
2
+ // `liteManagedBy`): a template of @starci/hfs that a managed slot names (templates/<scope or common>/<group>/<path, each leading dot
3
+ // dropped>), declared by the slot manifest and nowhere else, and an app's render of one, which `starci app sync` writes. Only a template
4
+ // with no `{{variable}}` is a render candidate, and only a file whose text equals that template (line endings aside) is its render:
5
+ // a file that differs in any byte stays authored.
6
+ import { braceVariants } from './glob.mjs';
7
+
8
+ const SIDES = Object.freeze(['be', 'fe']);
9
+ const lf = (text) => String(text).replace(/\r\n/g, '\n');
10
+ /** A literal file path of a slot (no wildcard or variable): only those are whole managed files. */
11
+ const literal = (file) => !/[*?[<]/.test(file);
12
+ const undotted = (file) => file.split('/').map((segment) => segment.replace(/^\./, '')).join('/');
13
+
14
+ /** The scope (app, be or fe) and scope-relative path of an app-relative path, one per profile a slot may hold it in. */
15
+ const placementsOf = (appRel) => [
16
+ { scope: 'app', file: appRel },
17
+ ...SIDES.filter((side) => appRel.startsWith(`${side}/`)).map((side) => ({ scope: side, file: appRel.slice(side.length + 1) })),
18
+ ];
19
+
20
+ /**
21
+ * The template names (relative to the @starci/hfs templates folder) a managed slot of `manifest` renders `appRel` from, in every
22
+ * edition: [] when no managed slot lists that literal file.
23
+ */
24
+ export function managedTemplatesOf(manifest, appRel) {
25
+ const placements = placementsOf(appRel);
26
+ const matches = managedFilesOf(manifest).filter((managed) => placements.some(({ scope, file }) => managed.scope === scope && managed.file === file));
27
+ return [...new Set(matches.map((managed) => managed.name))];
28
+ }
29
+
30
+ /** Every literal file of every managed slot, with its scope and the template name it is rendered from: [{scope, file, name}]. */
31
+ function managedFilesOf(manifest) {
32
+ return (manifest?.slots ?? []).flatMap((slot) => {
33
+ const groups = [...new Set([slot.managedBy, slot.liteManagedBy].filter((group) => typeof group === 'string'))];
34
+ const paths = [slot.path, slot.lite?.path].filter((value) => typeof value === 'string').flatMap(braceVariants).filter(literal);
35
+ return (slot.profiles ?? []).flatMap((scope) => paths.flatMap((file) => groups.flatMap((group) =>
36
+ [scope, 'common'].map((dir) => ({ scope, file, name: `${dir}/${group}/${undotted(file)}` })))));
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Every template name a managed slot of `manifest` declares (its literal files in each profile, edition group and scope or common
42
+ * folder): the canon source `starci app sync` renders from, which the slot manifest itself names, so a template is declared there and
43
+ * nowhere else.
44
+ */
45
+ export function declaredTemplateNames(manifest) {
46
+ return new Set(managedFilesOf(manifest).map((managed) => managed.name));
47
+ }
48
+
49
+ /** Whether `text` is the render of one of `templates` ([{name, text}]): a template with no variable whose text equals it. */
50
+ export const isManagedRender = (text, templates) =>
51
+ templates.some((template) => typeof template.text === 'string' && !template.text.includes('{{') && lf(template.text) === lf(text));
@@ -0,0 +1,118 @@
1
+ // uat-run-folder.mjs - the pure half of `starci uat record` (scripts/uat/uat-record.mjs): what a finished UAT run folder must
2
+ // hold, how a verified blob is curated into an example's fixture store, the work/evidence@1 document of one run and its outcome
3
+ // rule. The verb owns the browser, the UAT slot and the artifact store; this module owns the shapes. It is bundled into
4
+ // @starci/hfs (sync-runtime.mjs), whose spec src/uat-run-folder.spec.mjs proves it (owner ruling no-runtime-specs).
5
+ import crypto from 'node:crypto';
6
+ import { sha256 } from '../../engine/digest.mjs';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseYaml } from '../../engine/yaml.mjs';
10
+ import { byCodeUnit } from '../../engine/by-code-unit.mjs';
11
+
12
+ export const UAT_RECORD_FLOW_UNKNOWN = 'UAT_RECORD_FLOW_UNKNOWN';
13
+ export const UAT_RECORD_RUN_INCOMPLETE = 'UAT_RECORD_RUN_INCOMPLETE';
14
+ export const UAT_RECORD_ASSET_INVALID = 'UAT_RECORD_ASSET_INVALID';
15
+ const OUTCOMES = new Set(['pass', 'fail', 'partial-pass']);
16
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
17
+ const WEBM = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
18
+ const RUN_PARTS = Object.freeze(['manifest.yaml', 'result.md', 'screens', 'videos']);
19
+ const FOLDERS = Object.freeze([['screens', '.png', 'image/png', PNG], ['videos', '.webm', 'video/webm', WEBM]]);
20
+
21
+ /** A typed refusal of `starci uat record`. */
22
+ export const uatRecordRefusal = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code });
23
+
24
+ /** A run id in the evidence schema's shape: <UTC yyyymmddThhmmssZ>-<8 hex>. */
25
+ export const newRunId = (now = new Date(), random = crypto.randomBytes(4).toString('hex')) =>
26
+ `${now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}-${random}`;
27
+
28
+ const startsWith = (file, signature) => {
29
+ const fd = fs.openSync(file, 'r');
30
+ try {
31
+ const head = Buffer.alloc(signature.length);
32
+ fs.readSync(fd, head, 0, head.length, 0);
33
+ return head.equals(signature);
34
+ } finally { fs.closeSync(fd); }
35
+ };
36
+
37
+ /** The refusal text of a run folder whose top level is not exactly its four parts, or null. */
38
+ function partsProblem(top) {
39
+ const missing = RUN_PARTS.filter((name) => !top.includes(name));
40
+ const extra = top.filter((name) => !RUN_PARTS.includes(name));
41
+ if (!missing.length && !extra.length) return null;
42
+ const said = [`the run folder holds exactly ${RUN_PARTS.join(', ')}`];
43
+ if (missing.length) said.push(`missing ${missing.join(', ')}`);
44
+ if (extra.length) said.push(`unexpected ${extra.join(', ')}`);
45
+ return said.join('; ');
46
+ }
47
+
48
+ /**
49
+ * Check a finished run folder: exactly manifest.yaml, result.md, screens/ (one or more PNG) and videos/ (one or more WebM), every
50
+ * file non-empty and of its type by its bytes. Returns {manifest, files: [{name, file, mediaType}]} or throws a refusal.
51
+ */
52
+ export function readRunFolder(runDir) {
53
+ const problem = partsProblem(fs.readdirSync(runDir).sort(byCodeUnit));
54
+ if (problem) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, problem);
55
+ const files = [];
56
+ const add = (name, mediaType, signature) => {
57
+ const file = path.join(runDir, ...name.split('/'));
58
+ const stat = fs.statSync(file);
59
+ if (!stat.isFile() || stat.size === 0) throw uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `${name} is empty or not a file`);
60
+ if (signature && !startsWith(file, signature)) throw uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `${name} is not a ${mediaType} file by its bytes`);
61
+ files.push({ name, file, mediaType });
62
+ };
63
+ add('manifest.yaml', 'application/yaml');
64
+ add('result.md', 'text/markdown');
65
+ for (const [dir, ext, mediaType, signature] of FOLDERS) {
66
+ const names = fs.readdirSync(path.join(runDir, dir)).sort(byCodeUnit);
67
+ if (!names.length) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `${dir}/ holds no ${ext} file`);
68
+ for (const name of names) {
69
+ if (path.extname(name).toLowerCase() !== ext) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `${dir}/${name} is not a ${ext} file`);
70
+ add(`${dir}/${name}`, mediaType, signature);
71
+ }
72
+ }
73
+ let manifest;
74
+ try { manifest = parseYaml(fs.readFileSync(path.join(runDir, 'manifest.yaml'), 'utf8')); }
75
+ catch (error) { throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `manifest.yaml does not parse: ${error.message}`); }
76
+ if (!manifest || !OUTCOMES.has(manifest.outcome)) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, 'manifest.yaml carries no outcome of pass, fail or partial-pass');
77
+ return { manifest, files };
78
+ }
79
+
80
+ /**
81
+ * Where one verified blob goes in an example's fixture store (`root`) and what its sidecar says: <root>/<ab>/<sha> and <sha>.json.
82
+ * The fixture store is tracked source written only by `starci uat record`, which writes these paths and re-hashes the result with
83
+ * curatedProblem. Pure: no write here.
84
+ */
85
+ export function curationOf(root, { sha256: digest, bytes, mediaType, createdAt }) {
86
+ const dir = path.join(root, digest.slice(0, 2));
87
+ const file = path.join(dir, digest);
88
+ return { dir, file, sidecar: `${file}.json`, sidecarText: JSON.stringify({ size: bytes.length, mediaType, createdAt }) };
89
+ }
90
+
91
+ /** The refusal of a curated blob whose stored bytes do not hash to its name, or null. */
92
+ export const curatedProblem = (digest, storedBytes) => (sha256(storedBytes) === digest
93
+ ? null
94
+ : uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `the curated blob ${digest.slice(0, 12)} does not hash to its name`));
95
+
96
+ /** The verdict: pass only when the walk exited 0 and its own manifest says pass. */
97
+ export const recordedOutcome = (exitCode, manifest) => (exitCode === 0 ? manifest.outcome : 'fail');
98
+
99
+ /**
100
+ * The work/evidence@1 document of one recorded run. `cited` is [{name, sha256}] for every file of the run folder; `recordDigest`
101
+ * is the sha256 of the flow record's index.yaml as it stands.
102
+ */
103
+ export function evidenceOf({ recordId, recordDigest, runId, outcome, cited, command, environment, capturedAt, baseUrl }) {
104
+ const byDir = (dir) => cited.filter((entry) => entry.name.startsWith(`${dir}/`)).map(({ name, sha256 }) => ({ name, sha256 }));
105
+ const result = cited.find((entry) => entry.name === 'result.md');
106
+ const stack = baseUrl
107
+ ? { stack: `The walk drove the app served at ${baseUrl} and the dev stack its .starcistacks declares, in a real browser recorded by the runtime's Playwright recorder.` }
108
+ : {};
109
+ return {
110
+ schema: 'work/evidence@1',
111
+ record: recordId,
112
+ recordDigest,
113
+ run: { id: runId, outcome, result: { name: result.name, sha256: result.sha256 }, screens: byDir('screens'), videos: byDir('videos') },
114
+ outcome,
115
+ assets: cited.map(({ name, sha256 }) => ({ name: `runs/${runId}/${name}`, sha256 })),
116
+ provenance: { actor: 'uat-record', tool: `starci uat record --flow ${recordId} -- ${command.join(' ')}`, environment, capturedAt, ...stack },
117
+ };
118
+ }
package/stack/prepare.mjs CHANGED
@@ -8,7 +8,7 @@ import { approvalMissing, refusal } from './errors.mjs';
8
8
  import { installAge } from './age-install.mjs';
9
9
  import { describeClash, edgeCollisions } from './edge-alias.mjs';
10
10
  import { checkLiveRestore, fixLiveRestore } from './live-restore.mjs';
11
- import { remoteDocker, shellQuote, withRunner } from './remote.mjs';
11
+ import { releasePointerScript, remoteDocker, shellQuote, withRunner } from './remote.mjs';
12
12
 
13
13
  const MIN_FREE_GB = 10;
14
14
  const ADDRESS = /^[0-9a-zA-Z.:-]+$/u;
@@ -220,11 +220,18 @@ function managedCount(run, kind, stack) {
220
220
  return splitLines(remoteDocker(run, [kind, 'ls', '--filter', `label=${stack}.managed=true`, '-q']).stdout).length;
221
221
  }
222
222
 
223
- function checkPointers(run, report, config) {
223
+ /** The release pointers (`current`, `previous`) of the stack's releases directory: {current, previous} (null for an absent link); an unreadable pointer is reported bad, never read as absent. */
224
+ export function checkPointers(run, report, config) {
224
225
  const base = shellQuote(releasesBase(config));
225
226
  const pointers = {};
226
227
  for (const pointer of ['current', 'previous']) {
227
- const value = run(`cd ${base} 2>/dev/null && [ -L ${pointer} ] && basename "$(readlink ${pointer})" || true`).stdout.trim();
228
+ const answer = run(releasePointerScript(base, pointer));
229
+ if (answer.status !== 0) {
230
+ report.bad(`release ${pointer} could not be read: ${firstLine(answer.stderr)}`);
231
+ pointers[pointer] = null;
232
+ continue;
233
+ }
234
+ const value = answer.stdout.trim();
228
235
  pointers[pointer] = value === '' ? null : value;
229
236
  if (value === '') report.ok(`release ${pointer}: none`);
230
237
  else if (RELEASE_ID.test(value)) report.ok(`release ${pointer}: ${value}`);
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { firstLine, lines as splitLines } from './context.mjs';
9
9
  import { isReleaseId } from './digest.mjs';
10
10
  import { failure, refusal } from './errors.mjs';
11
+ import { releaseListScript, releasePointerScript } from './remote.mjs';
11
12
 
12
13
  const BASE = /^\/[A-Za-z0-9._/-]+$/u;
13
14
  const FILES = ['stack.yaml', 'release.json'];
@@ -45,7 +46,7 @@ export function createReleaseStore(config, ssh) {
45
46
  }
46
47
 
47
48
  async function pointer(name) {
48
- const out = await run(`cd "${base}" 2>/dev/null && [ -L ${name} ] && basename "$(readlink ${name})" || true`);
49
+ const out = await run(releasePointerScript(`"${base}"`, name));
49
50
  const id = out.trim();
50
51
  return isReleaseId(id) ? id : '';
51
52
  }
@@ -80,8 +81,7 @@ export function createReleaseStore(config, ssh) {
80
81
  },
81
82
  /** The stored releases, newest first: [{ id, current, previous }]. */
82
83
  async list() {
83
- const pointerScript = (name) => `if [ -L "${base}/${name}" ]; then basename "$(readlink "${base}/${name}")"; else printf '\\n'; fi`;
84
- const response = await run(`${pointerScript('current')}; ${pointerScript('previous')}; cd "${base}/releases" 2>/dev/null && ls -1t || true`);
84
+ const response = await run(`${releasePointerScript(`"${base}"`, 'current')}; ${releasePointerScript(`"${base}"`, 'previous')}; ${releaseListScript(`"${base}"`)}`);
85
85
  const [current, previous, ...entries] = response.split('\n');
86
86
  const now = isReleaseId(current) ? current : '';
87
87
  const before = isReleaseId(previous) ? previous : '';
package/stack/remote.mjs CHANGED
@@ -30,6 +30,15 @@ export function shellQuote(value) {
30
30
  return ["'", text.replaceAll("'", String.raw`'\''`), "'"].join('');
31
31
  }
32
32
 
33
+ /**
34
+ * The shell that prints the release the symlink `name` under `quotedBase` (an already-quoted directory) points at, one line; an absent link prints an
35
+ * empty line and succeeds. Only the absent link is expected: a `readlink` or `basename` that fails fails the script, and the caller reports it.
36
+ */
37
+ export const releasePointerScript = (quotedBase, name) => `if [ -L ${quotedBase}/${name} ]; then basename "$(readlink ${quotedBase}/${name})"; else printf '\\n'; fi`;
38
+
39
+ /** The shell that lists the stored releases under `quotedBase`/releases, newest first; no releases directory prints nothing and succeeds, a failing `ls` fails the script. */
40
+ export const releaseListScript = (quotedBase) => `if [ -d ${quotedBase}/releases ]; then ls -1t ${quotedBase}/releases; fi`;
41
+
33
42
  /** Runs `docker <args>` on the manager through `run` (every argument quoted). */
34
43
  export const remoteDocker = (run, args) => run(['docker', ...args.map(shellQuote)].join(' '));
35
44