@starci/hfs 4.7.22 → 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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/candidate-ci.mjs +20 -10
- package/release/gate.mjs +37 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +17 -17
- package/runtime/knowledge/hfs/slots.yaml +20 -1
- package/runtime/knowledge/patterns/be/saga.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +12 -12
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
- package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/lib/gate-loosening.mjs +248 -0
- package/runtime/scripts/lib/managed-json.mjs +51 -0
- package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
- package/stack/prepare.mjs +10 -3
- package/stack/release-store.mjs +3 -3
- package/stack/remote.mjs +9 -0
- package/sync/ci-commands.mjs +38 -90
- package/sync/index.mjs +16 -5
- package/templates/app/browser-config/browser/tsconfig.json +11 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +0 -1
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
- package/templates/fe/auth/supabase-client.ts.tpl +7 -7
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
- package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
- 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
|
-
|
|
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
|
|
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}`);
|
package/stack/release-store.mjs
CHANGED
|
@@ -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(`
|
|
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
|
|
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
|
|