@starci/hfs 4.7.19 → 4.7.21

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.21 - 2026-10-22
4
+ - Fixed: the release gate's Sonar read retries a network error or HTTP 5xx up to 5 times with backoff inside its 600 s wait; a 4xx and every gate status keep their verdict. Spec: `src/sonar-quality-gate.spec.mjs`.
5
+ - Fixed: `starci app db dump` archives its parts with tar run inside the scratch directory and relative names only (no `-C`, no absolute path): GNU tar, first on PATH under Git Bash on Windows, read the absolute drive-letter archive path as a remote host and every backup failed with `DB_BACKUP_TOOL_FAILED`. Spec: `src/db-dump-archive.spec.mjs`.
6
+ - Fixed: the bundled stacks gate validates a declaration's `operations` block with the composed provision-operation schema instead of crashing on its cross-file `$ref`; the bundled schema lets a SonarCloud admin credential name a runtime secret.env variable (`hostConnection: sonar-runtime-secret-env`).
7
+
8
+ ## 4.7.20 - 2026-10-22
9
+ - Changed: the bundled failure-code catalog carries the Sonar quality-gate ensure results (`sonar-gate-*`); canon pins follow the matching package cohort.
10
+
3
11
  ## 4.7.19 - 2026-10-21
4
12
  - Changed: supersedes 4.7.18 (app lint broken: the canons' Sonar way block carries its own ignores, which `starci app lint` refused as an unsupported source scope, so it exited 2 without running ESLint and reported 0 findings); 4.7.18 is not used.
5
13
  - Fixed: `starci app lint` reads the canons' flat config as flat config defines it: global ignores prune the source walk and a block's own ignores narrow only that block, so the Sonar way block no longer makes the source scope unavailable (exit 2 without ESLint); a file is a source when some block's files match it and that block's ignores do not.
package/db/dump.mjs CHANGED
@@ -17,6 +17,19 @@ export function rowcounts(session) {
17
17
  });
18
18
  }
19
19
 
20
+ /** The archive name inside the plaintext scratch directory. */
21
+ export const ARCHIVE_NAME = 'backup.tar';
22
+
23
+ /**
24
+ * Archive `parts` of `scratch` into scratch/backup.tar. tar runs inside scratch with relative names only: no -C and no
25
+ * absolute path, because GNU tar (Git Bash puts it first on Windows) reads a drive-letter path (the `C:` of an absolute Windows path) as a remote `host:path`, while bsdtar
26
+ * does not; relative arguments mean the same to both. Returns the archive's absolute path.
27
+ */
28
+ export function archiveParts(ctx, scratch, parts) {
29
+ backupTool(ctx, 'tar', ['-cf', ARCHIVE_NAME, ...parts], { cwd: scratch });
30
+ return path.join(scratch, ARCHIVE_NAME);
31
+ }
32
+
20
33
  /** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
21
34
  function dumpDatabase(ctx, options = {}) {
22
35
  if (!options.out) throw refusal('DB_USAGE', '--out is required');
@@ -41,8 +54,7 @@ function dumpDatabase(ctx, options = {}) {
41
54
  const counts = rowcounts(session);
42
55
  fs.writeFileSync(path.join(scratch, 'rowcounts.json'), `${JSON.stringify(counts, null, 2)}\n`, { mode: 0o600 });
43
56
  const parts = [...jobs.map(([file]) => file), 'rowcounts.json'];
44
- const tar = path.join(scratch, 'backup.tar');
45
- backupTool(ctx, 'tar', ['-cf', tar, '-C', scratch, ...parts]);
57
+ const tar = archiveParts(ctx, scratch, parts);
46
58
  const bytes = encryptFile(ctx, tar, archive, recipients);
47
59
  const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.ref ?? null,
48
60
  at, file: path.basename(archive), bytes, sha256: sha256(archive), parts,
@@ -28,6 +28,20 @@ const undef = { t: 'undef' };
28
28
  const nul = { t: 'nul' };
29
29
  const free = (why) => ({ t: 'free', why });
30
30
 
31
+ /**
32
+ * JavaScript's abstract equality (`==`) for the literal kinds this graph forces - string, number, boolean - without
33
+ * the `==` operator: one type compares strictly, a boolean compares as its number, and a number meets a string as
34
+ * Number(string), exactly as the language coerces them (so '' == 0, '1' == true, NaN never equals).
35
+ */
36
+ export function looseLiteralEqual(x, y) {
37
+ if (typeof x === typeof y) return x === y;
38
+ if (typeof x === 'boolean') return looseLiteralEqual(Number(x), y);
39
+ if (typeof y === 'boolean') return looseLiteralEqual(x, Number(y));
40
+ if (typeof x === 'number' && typeof y === 'string') return x === Number(y);
41
+ if (typeof x === 'string' && typeof y === 'number') return Number(x) === y;
42
+ return false;
43
+ }
44
+
31
45
  export function createGraphReader({ ts, host }) {
32
46
  const files = new Map();
33
47
  const classes = new Map();
@@ -194,7 +208,7 @@ export function createGraphReader({ ts, host }) {
194
208
  const nullish = (v) => v.t === 'undef' || v.t === 'nul';
195
209
  if (nullish(a) && nullish(b)) return loose ? true : a.t === b.t;
196
210
  if (nullish(a) || nullish(b)) return ['lit', 'class', 'obj', 'arr'].includes(nullish(a) ? b.t : a.t) ? false : null;
197
- if (a.t === 'lit' && b.t === 'lit') return loose ? a.v == b.v : a.v === b.v; // eslint-disable-line eqeqeq
211
+ if (a.t === 'lit' && b.t === 'lit') return loose ? looseLiteralEqual(a.v, b.v) : a.v === b.v;
198
212
  if (a.t === 'class' && b.t === 'class') return a === b;
199
213
  return null;
200
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.19",
3
+ "version": "4.7.21",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,8 @@
2
2
  // that the report was uploaded; the server computes the analysis afterwards. This reads the task the scanner wrote
3
3
  // (`.scannerwork/report-task.txt`), polls `api/ce/task` until the compute-engine task ends, then reads the quality gate of that
4
4
  // analysis from `api/qualitygates/project_status` with the same token. OK passes; ERROR is red and names every failed condition;
5
- // a task that ends FAILED or CANCELED, an unreadable answer and a deadline passed are typed refusals in the row's detail.
5
+ // a task that ends FAILED or CANCELED, an unreadable answer and a deadline passed are typed refusals in the row's detail. A transient
6
+ // read failure (a network error or an HTTP 5xx) is retried with a bounded backoff inside the same wait before it is unreadable.
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
 
@@ -21,13 +22,36 @@ function reportTask(cwd) {
21
22
  return Object.fromEntries(text.split(/\r?\n/u).map((line) => line.split('=')).filter(([key, ...rest]) => key && rest.length).map(([key, ...rest]) => [key.trim(), rest.join('=').trim()]));
22
23
  }
23
24
 
24
- /** One authenticated JSON GET; a failed request or a non-JSON body throws. */
25
- async function getJson(ctx, url, token) {
26
- const response = await ctx.fetch(url, { headers: { Authorization: `Basic ${Buffer.from(`${token}:`).toString('base64')}` }, signal: AbortSignal.timeout(30_000) });
27
- if (!response.ok) throw new Error(`HTTP ${response.status} from ${url.split('?')[0]}`);
25
+ /** The bound of a transient read failure: attempts per request and the first pause, doubled after each retry (milliseconds). */
26
+ const TRANSIENT_RETRY = Object.freeze({ attempts: 5, firstPause: 2_000 });
27
+
28
+ /** One authenticated JSON GET; a failed request or a non-JSON body throws. A network error or an HTTP 5xx is marked transient. */
29
+ async function getJsonOnce(ctx, url, token) {
30
+ let response;
31
+ try {
32
+ response = await ctx.fetch(url, { headers: { Authorization: `Basic ${Buffer.from(`${token}:`).toString('base64')}` }, signal: AbortSignal.timeout(30_000) });
33
+ } catch (error) {
34
+ throw Object.assign(new Error(`${String(error?.message ?? error)} (${url.split('?')[0]})`), { transient: true });
35
+ }
36
+ if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status} from ${url.split('?')[0]}`), { transient: response.status >= 500 });
28
37
  return response.json();
29
38
  }
30
39
 
40
+ /**
41
+ * getJsonOnce, retried only when the failure is transient (a network error or an HTTP 5xx): at most TRANSIENT_RETRY.attempts
42
+ * tries, pausing 2 s, 4 s, 8 s, ... between them, and never past `deadline`. A 4xx (401 and 403 included) and an unreadable body
43
+ * throw at once; the last transient failure throws as itself, so a retry never turns an unreadable answer into a verdict.
44
+ */
45
+ async function getJson(ctx, url, token, { sleep, deadline }) {
46
+ for (let attempt = 1, pause = TRANSIENT_RETRY.firstPause; ; attempt += 1, pause *= 2) {
47
+ try { return await getJsonOnce(ctx, url, token); }
48
+ catch (error) {
49
+ if (!error.transient || attempt >= TRANSIENT_RETRY.attempts || ctx.now() + pause > deadline) throw error;
50
+ await sleep(pause);
51
+ }
52
+ }
53
+ }
54
+
31
55
  /** The failed conditions of a quality gate answer, as `metric comparator threshold (actual value)` lines. */
32
56
  const failedConditions = (status) => (status?.conditions ?? []).filter((condition) => condition.status === 'ERROR')
33
57
  .map((condition) => `${condition.metricKey} ${condition.comparator ?? ''} ${condition.errorThreshold ?? ''} (actual ${condition.actualValue ?? '?'})`.replace(/\s+/gu, ' '));
@@ -42,10 +66,11 @@ export async function sonarQualityGate(ctx, { cwd, host, token, wait = QUALITY_G
42
66
  const server = String(task.serverUrl || host).replace(/\/$/u, '');
43
67
  const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
44
68
  const deadline = ctx.now() + wait.deadline;
69
+ const retry = { sleep, deadline };
45
70
  let analysisId = null;
46
71
  try {
47
72
  for (;;) {
48
- const state = (await getJson(ctx, `${server}/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`, token))?.task;
73
+ const state = (await getJson(ctx, `${server}/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`, token, retry))?.task;
49
74
  if (DONE.has(state?.status)) {
50
75
  if (state.status !== 'SUCCESS') return { pass: false, detail: `${SONAR_GATE_FAILED}: compute-engine task ${task.ceTaskId} ended ${state.status}${state.errorMessage ? `: ${state.errorMessage}` : ''}` };
51
76
  analysisId = state.analysisId;
@@ -55,7 +80,7 @@ export async function sonarQualityGate(ctx, { cwd, host, token, wait = QUALITY_G
55
80
  await sleep(wait.interval);
56
81
  }
57
82
  const query = analysisId ? `analysisId=${encodeURIComponent(analysisId)}` : `projectKey=${encodeURIComponent(task.projectKey ?? '')}`;
58
- const status = (await getJson(ctx, `${server}/api/qualitygates/project_status?${query}`, token))?.projectStatus;
83
+ const status = (await getJson(ctx, `${server}/api/qualitygates/project_status?${query}`, token, retry))?.projectStatus;
59
84
  if (status?.status === 'OK') return { pass: true, detail: 'exit 0, quality gate OK' };
60
85
  if (status?.status === 'ERROR') {
61
86
  const conditions = failedConditions(status);
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.27
32
+ version: 3.4.29
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.26
39
+ version: 8.4.28
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -50,7 +50,7 @@ pins:
50
50
  source: packages/stylelint/package.json
51
51
  why: 'the front-end stylelint canon: no-class-selector refuses a compound class selector (`div.card`); the brand layer accepts `--font-sans` and `--font-mono`; status-contrast measures the HeroUI soft pairs per theme; brand-layer-shape reads the shared :root,.light,.dark block and the info soft pair; appTokens come from loadAppTokens(import.meta.url) for the managed one-line stylelint.config.mjs.'
52
52
  '@starci/tsconfig':
53
- version: 2.0.4
53
+ version: 2.0.5
54
54
  group: starci
55
55
  install: registry
56
56
  side: both
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.19
79
+ version: 1.7.21
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.19
86
+ version: 4.7.21
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -3754,7 +3754,7 @@ rules:
3754
3754
  - {path: packages/eslint/be/sonar-parity-editions.spec.mjs, edition: full}
3755
3755
  - {path: packages/eslint/fe/sonar-parity-editions.spec.mjs, edition: lite}
3756
3756
  - {path: packages/eslint/fe/sonar-parity-editions.spec.mjs, edition: full}
3757
- law: "Code carries none of the smells SonarCloud measures that a lint rule can decide: no `await` inside a loop (S9382; an endless loop is spared), no `charCodeAt`/`fromCharCode` (S7758), no string that only escapes backslashes (S7780; a Next `matcher` is spared, it is read statically), no nested conditional expression (S3358), no `void` on anything but a call (S3735), no unused import (S1128), no import that is only handed on instead of `export ... from` (S7763), no prop of a component's props type the component never reads (S6767, front end), no `<video>` or `<audio>` without a captions track unless muted (S4084, front end), and, through typescript-eslint with type information, no deprecated API (S1874), no object turned into text (S6551) and no `async` function that never awaits and returns no promise (S7503). Every other rule of the Sonar way TypeScript profile with an ESLint equivalent (scripts/lib/sonar-way.mjs: eslint-plugin-sonarjs' Sonar way set, the core, typescript-eslint and unicorn rules it builds on) is on at error in both canons. The files a unit or an end-to-end spec names are outside the scan, so outside these rules."
3757
+ law: "Code carries none of the smells SonarCloud measures that a lint rule can decide: no `await` inside a loop (S9382; an endless loop is spared), no `charCodeAt`/`fromCharCode` (S7758), no string that only escapes backslashes (S7780; a Next `matcher` is spared, it is read statically), no nested conditional expression (S3358), no `void` on anything but a call (S3735), no unused import (S1128), no import that is only handed on instead of `export ... from` (S7763), no prop of a component's props type the component never reads (S6767, front end), no `<video>` or `<audio>` without a captions track unless muted (S4084, front end), and, through typescript-eslint with type information, no deprecated API (S1874), no object turned into text (S6551) and no `async` function that never awaits and returns no promise (S7503). No function takes more than seven parameters (S107), except the constructor of an injectable class, whose parameters are its injected dependencies. Every other rule of the Sonar way TypeScript profile with an ESLint equivalent that reports what the server reports on the same source (scripts/lib/sonar-way.mjs: eslint-plugin-sonarjs' Sonar way set and the core and unicorn rules it builds on) is on at error in both canons; a port that does not match the server is left to the server. The files a unit or an end-to-end spec names are outside the scan, so outside these rules."
3758
3758
  kinds: [lint, codemod]
3759
3759
  gates: [pre-commit, settle, land, ci]
3760
3760
  failureCodes: ["SCAN_SMELL"]
@@ -3775,6 +3775,8 @@ rules:
3775
3775
  - {kind: eslint-fe, id: prefer-export-from}
3776
3776
  - {kind: eslint-fe, id: no-unused-prop-types}
3777
3777
  - {kind: eslint-fe, id: media-has-caption}
3778
+ - {kind: eslint-be, id: max-params}
3779
+ - {kind: eslint-fe, id: max-params}
3778
3780
  - id: R236
3779
3781
  code: "RT_GATE_LOOSENING"
3780
3782
  editions: [lite, full]
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "fb2a551d3cb254cc42fa5c95dc1a32af973df14313385f03703bc209b2badb8a",
4
+ "hash": "4018e220cb9abde27d6d8cd79cd502c4d0c64a5312d7549c8e631553a22cb878",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -1240,7 +1240,8 @@ export const CATALOG = {
1240
1240
  "disabled or unavailable Sonar or an unset token refuses RELEASE_SONAR_UNAVAILABLE",
1241
1241
  "a scanner exit 0 is not a pass: the row polls api/ce/task until the analysis ends, then reads api/qualitygates/project_status with the same token",
1242
1242
  "OK passes; ERROR is red as RELEASE_SONAR_GATE_FAILED naming each failed condition, as is a FAILED or CANCELED task",
1243
- "a task still running after 600 s is RELEASE_SONAR_GATE_TIMEOUT; no report-task, an HTTP error or no gate is RELEASE_SONAR_GATE_UNREADABLE",
1243
+ "a task still running after 600 s is RELEASE_SONAR_GATE_TIMEOUT; no report-task, an HTTP 4xx or no gate is RELEASE_SONAR_GATE_UNREADABLE",
1244
+ "a network error or HTTP 5xx is retried up to 5 times with backoff inside the 600 s; still failing, it is RELEASE_SONAR_GATE_UNREADABLE",
1244
1245
  "OWNER RULING candidate-ci (2026-10-18): the gate no longer reads the default-branch CI; the verdict is the candidate run of the sha",
1245
1246
  "release.requireCi (default false, owner ruling release-require-ci-per-project) decides whether push waits for CI; the local gate is never skipped",
1246
1247
  "requireCi false: push needs the tag and the green L4 record, runs the dry run, pushes atomically and states CI was not required",
@@ -10182,13 +10183,14 @@ export const CATALOG = {
10182
10183
  "runtimeSide": "host"
10183
10184
  },
10184
10185
  "project": {
10185
- "summary": "ensure a declared Sonar project through its provider adapter",
10186
+ "summary": "ensure a declared Sonar project, or move it to its declared key, through its provider adapter",
10186
10187
  "owner": "runtime",
10187
10188
  "positional": [
10188
10189
  {
10189
10190
  "name": "action",
10190
10191
  "enum": [
10191
- "ensure"
10192
+ "ensure",
10193
+ "rekey"
10192
10194
  ],
10193
10195
  "required": true,
10194
10196
  "variadic": false
@@ -10234,7 +10236,8 @@ export const CATALOG = {
10234
10236
  },
10235
10237
  "json": "always",
10236
10238
  "examples": [
10237
- "starci sonar project ensure --target quality --operation project-bootstrap"
10239
+ "starci sonar project ensure --target quality --operation project-bootstrap",
10240
+ "starci sonar project rekey --target quality --operation project-rename"
10238
10241
  ],
10239
10242
  "editions": [
10240
10243
  "full",
@@ -10252,7 +10255,9 @@ export const CATALOG = {
10252
10255
  "owner"
10253
10256
  ],
10254
10257
  "conventions": [
10255
- "a target and existing accepted operation scope bind provider effects; execute grants no authority"
10258
+ "a target and existing accepted operation scope bind provider effects; execute grants no authority",
10259
+ "rekey moves the project at previousKey to projectKey (api/projects/update_key); its analyses, issues and quality gate stay with it",
10260
+ "rekey is verified when the new key reads back and the old key no longer does"
10256
10261
  ],
10257
10262
  "operationPolicy": {
10258
10263
  "ensure": {
@@ -10265,6 +10270,17 @@ export const CATALOG = {
10265
10270
  "checkpoints": [
10266
10271
  "account-authority"
10267
10272
  ]
10273
+ },
10274
+ "rekey": {
10275
+ "action": "sonar.project.rekey",
10276
+ "effect": "remote",
10277
+ "credentials": [
10278
+ "admin"
10279
+ ],
10280
+ "destination": "none",
10281
+ "checkpoints": [
10282
+ "account-authority"
10283
+ ]
10268
10284
  }
10269
10285
  },
10270
10286
  "runtimeSide": "host"
@@ -3,6 +3,8 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { lsFiles } from '../api/git/ls-files.mjs';
5
5
  import {parseYaml} from '../../engine/yaml.mjs';
6
+ import { validateAgainstSchema } from '../lib/json-schema.mjs';
7
+ import { PROVISION_OPERATION_SCHEMA_REF, provisionOperationSchema } from '../lib/provision-operation-schema.mjs';
6
8
  import {slash} from '../lib/path-key.mjs';
7
9
  import { isInside, walkFiles } from '../lib/walk.mjs';
8
10
  import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../lib/swarm-stack.mjs';
@@ -92,7 +94,13 @@ function validateSchema(value){
92
94
  if(shape.minItems&&node.length<shape.minItems)errors.push({path:at,message:'array too short'});
93
95
  node.forEach((item,index)=>shape.items&&check(item,shape.items,`${at}/${index}`));
94
96
  };
97
+ // The operations block refers to its own schema file: its owner's composed schema validates it, as the provision target owner does.
98
+ const checkOperations=(node,at)=>{
99
+ for(const error of validateAgainstSchema(node,provisionOperationSchema()))errors.push({path:at,message:error});
100
+ };
95
101
  const check=(node,shape,at)=>{
102
+ if(shape?.$ref===PROVISION_OPERATION_SCHEMA_REF)return checkOperations(node,at);
103
+ if(shape?.$ref&&!shape.$ref.startsWith('#/')){errors.push({path:at,message:`unresolved schema reference ${shape.$ref}`});return;}
96
104
  if(shape?.$ref)return check(node,resolve(shape.$ref),at);
97
105
  if(Array.isArray(shape?.anyOf)){
98
106
  checkAlternatives(node,shape,at,check);
@@ -0,0 +1,96 @@
1
+ // json-schema.mjs - a minimal draft-2020-12 JSON schema walker over the keywords the runtime's yaml schemas use.
2
+ import {isPlainObject} from '../../engine/plain-object.mjs';
3
+
4
+ // A minimal draft-2020-12 walker over the keywords op.schema.yaml uses. ajv is
5
+ // a devDependency; the runtime has no npm dependencies, so the walker is here.
6
+ /** Validate the supported schema keywords, including exact-one and at-least-one alternatives, without mutating input. */
7
+ export function validateAgainstSchema(value, schema) {
8
+ const errors = [];
9
+ const resolve = (ref) => String(ref).replace(/^#\//, '').split('/')
10
+ .reduce((node, key) => node?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], schema);
11
+ const typeOk = (node, type) => {
12
+ if (Array.isArray(type)) return type.some((t) => typeOk(node, t));
13
+ if (type === 'object') return isPlainObject(node);
14
+ if (type === 'array') return Array.isArray(node);
15
+ if (type === 'integer') return Number.isInteger(node);
16
+ if (type === 'null') return node === null;
17
+ return typeof node === type;
18
+ };
19
+ const validateType = (node, shape, at) => {
20
+ if (shape.type !== undefined && !typeOk(node, shape.type)) {
21
+ errors.push(`${at}: expected ${Array.isArray(shape.type) ? shape.type.join('|') : shape.type}`);
22
+ return false;
23
+ }
24
+ return true;
25
+ };
26
+ const validateString = (node, shape, at) => {
27
+ if (shape.minLength !== undefined && node.length < shape.minLength) errors.push(`${at}: empty or too short`);
28
+ if (shape.pattern && !new RegExp(shape.pattern, 'u').test(node)) errors.push(`${at}: ${JSON.stringify(node)} does not match ${shape.pattern}`);
29
+ };
30
+ const validateNumber = (node, shape, at) => {
31
+ if (shape.minimum !== undefined && node < shape.minimum) errors.push(`${at}: below minimum ${shape.minimum}`);
32
+ if (shape.maximum !== undefined && node > shape.maximum) errors.push(`${at}: above maximum ${shape.maximum}`);
33
+ };
34
+ const validateScalar = (node, shape, at) => {
35
+ if (Object.hasOwn(shape, 'const') && node !== shape.const) errors.push(`${at}: must be ${JSON.stringify(shape.const)}`);
36
+ if (Array.isArray(shape.enum) && !shape.enum.includes(node)) errors.push(`${at}: ${JSON.stringify(node)} is outside [${shape.enum.join(', ')}]`);
37
+ if (typeof node === 'string') validateString(node, shape, at);
38
+ if (typeof node === 'number') validateNumber(node, shape, at);
39
+ };
40
+ const validateArray = (node, shape, at, walk) => {
41
+ if (shape.minItems !== undefined && node.length < shape.minItems) errors.push(`${at}: needs at least ${shape.minItems} item(s)`);
42
+ if (shape.items) node.forEach((item, i) => walk(item, shape.items, `${at}[${i}]`));
43
+ };
44
+ const validateObject = (node, shape, at, walk) => {
45
+ if (shape.minProperties !== undefined && Object.keys(node).length < shape.minProperties) errors.push(`${at}: needs at least ${shape.minProperties} entr(y|ies)`);
46
+ for (const key of shape.required ?? []) if (!Object.hasOwn(node, key)) errors.push(`${at}: missing ${key}`);
47
+ for (const [key, child] of Object.entries(node)) {
48
+ const where = at === '$' ? `$.${key}` : `${at}.${key}`;
49
+ if (Object.hasOwn(shape.properties ?? {}, key)) walk(child, shape.properties[key], where);
50
+ else if (shape.additionalProperties === false) errors.push(`${where}: unknown key`);
51
+ else if (isPlainObject(shape.additionalProperties)) walk(child, shape.additionalProperties, where);
52
+ }
53
+ };
54
+ const validateAlternatives = (node, shape, at, walk) => {
55
+ for (const keyword of ['anyOf', 'oneOf']) {
56
+ if (!Object.hasOwn(shape, keyword)) continue;
57
+ const branches = shape[keyword];
58
+ if (!Array.isArray(branches) || branches.length === 0) { errors.push(`${at}: ${keyword} needs a nonempty schema list`); continue; }
59
+ const outcomes = branches.map((branch) => {
60
+ const start = errors.length;
61
+ walk(node, branch, at);
62
+ return errors.splice(start);
63
+ });
64
+ const matched = outcomes.filter((findings) => findings.length === 0).length;
65
+ if (keyword === 'oneOf' && matched > 1) errors.push(`${at}: oneOf matches more than one alternative`);
66
+ else if (matched === 0) {
67
+ errors.push(`${at}: ${keyword} matches no alternative`);
68
+ // The nearest branch explains a refusal without mixing findings from mutually exclusive shapes.
69
+ const nearest = outcomes.reduce((best, findings) => findings.length < best.length ? findings : best, outcomes[0]);
70
+ errors.push(...nearest);
71
+ }
72
+ }
73
+ };
74
+ const walk = (node, shape, at) => {
75
+ if (shape === true) return;
76
+ if (shape === false) { errors.push(`${at}: schema refuses this value`); return; }
77
+ if (!isPlainObject(shape)) { errors.push(`${at}: schema is invalid`); return; }
78
+ if (shape.$ref) {
79
+ const referenced = String(shape.$ref).startsWith('#/') ? resolve(shape.$ref) : undefined;
80
+ if (referenced === undefined) { errors.push(`${at}: schema reference is unresolved`); return; }
81
+ walk(node, referenced, at);
82
+ return;
83
+ }
84
+ validateAlternatives(node, shape, at, walk);
85
+ if (!validateType(node, shape, at)) return;
86
+ validateScalar(node, shape, at);
87
+ if (Array.isArray(node)) {
88
+ validateArray(node, shape, at, walk);
89
+ return;
90
+ }
91
+ if (!isPlainObject(node)) return;
92
+ validateObject(node, shape, at, walk);
93
+ };
94
+ walk(value, schema, '$');
95
+ return errors;
96
+ }
@@ -40,7 +40,7 @@ export const pathsOverlap = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a);
40
40
  * `trim` blanks at the ends, `collapse` folds '//' runs to '/', `dot` drops a leading './',
41
41
  * `glob` strips a trailing glob ('star': '*'/'**' with their slash, 'double': a '/**' only),
42
42
  * `fold` lowercases. A trailing '/' always goes. Null reads as ''.
43
- * @param {unknown} p
43
+ * @param {string|number|null|undefined} p
44
44
  * @param {{trim?: boolean, collapse?: boolean, dot?: boolean, glob?: string|null, fold?: boolean}} [options]
45
45
  */
46
46
  export const normPath = (p, { trim = false, collapse = false, dot = false, glob = null, fold = false } = {}) => {
@@ -0,0 +1,17 @@
1
+ // The schema of a stack declaration's `operations` block, composed once for its two readers: the provision target owner and the
2
+ // stacks gate. provision-operation.schema.yaml refers to the service credential of application-stacks.schema.yaml; the composition
3
+ // inlines that definition so the self-contained walker of json-schema.mjs resolves every reference.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parseYaml } from '../../engine/yaml.mjs';
7
+ import { skillRoot } from '../../engine/runtime-root.mjs';
8
+
9
+ /** The `$ref` application-stacks.schema.yaml uses for its `operations` block. */
10
+ export const PROVISION_OPERATION_SCHEMA_REF = 'provision-operation.schema.yaml';
11
+
12
+ /** The composed `operations` schema, read from this runtime's modules/schemas. */
13
+ export function provisionOperationSchema() {
14
+ const schema = parseYaml(fs.readFileSync(path.join(skillRoot, 'modules/schemas', PROVISION_OPERATION_SCHEMA_REF), 'utf8'));
15
+ const stacks = parseYaml(fs.readFileSync(path.join(skillRoot, 'modules/schemas/application-stacks.schema.yaml'), 'utf8'));
16
+ return { ...schema, $defs: { ...schema.$defs, serviceCredential: stacks.$defs.serviceCredential } };
17
+ }
package/sync/index.mjs CHANGED
@@ -340,7 +340,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
340
340
  nodeMajor: String(NODE_MAJOR),
341
341
  sonarKey: sonarKey ?? app.project,
342
342
  sonarHostLine: sonarHostLine(quality.sonar),
343
- sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
343
+ sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
344
344
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
345
345
  lcovReport: LCOV_REPORT,
346
346
  ...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app }),
@@ -7,7 +7,7 @@ git diff --cached --name-only --diff-filter=ACMR -z --relative=be -- '*.ts' '*.m
7
7
  xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd be && npx eslint -- "$@"; fi' sh || exit $?
8
8
  git diff --cached --name-only --diff-filter=ACMR -z --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' |
9
9
  xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd fe && npx eslint --max-warnings=0 --no-warn-ignored -- "$@"; fi' sh || exit $?
10
- styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css' || true)
10
+ styles=$(git diff --cached --name-only --diff-filter=ACMR --relative=fe -- '*.css') || exit $?
11
11
  if [ -n "$styles" ]; then
12
12
  (cd fe && npx stylelint $styles)
13
13
  fi
@@ -10,4 +10,4 @@ contracts/
10
10
  .starcistacks/
11
11
  .starciwork/
12
12
  **/__generated__/
13
- **/messages/**
13
+ **/i18n/messages/**
@@ -3,7 +3,8 @@ import type { MessageBundle } from "@modules/platform/i18n"
3
3
  /** Display text of the identity codes, Vietnamese and English. */
4
4
  export const IDENTITY_MESSAGES: MessageBundle = {
5
5
  vi: {
6
- "errors.IDENTITY_UNAUTHENTICATED": "B\u1ea1n c\u1ea7n \u0111\u0103ng nh\u1eadp \u0111\u1ec3 th\u1ef1c hi\u1ec7n thao t\u00e1c n\u00e0y.",
6
+ "errors.IDENTITY_UNAUTHENTICATED":
7
+ "B\u1ea1n c\u1ea7n \u0111\u0103ng nh\u1eadp \u0111\u1ec3 th\u1ef1c hi\u1ec7n thao t\u00e1c n\u00e0y.",
7
8
  },
8
9
  en: {
9
10
  "errors.IDENTITY_UNAUTHENTICATED": "You need to sign in to do this.",
@@ -3,7 +3,8 @@ import type { MessageBundle } from "@modules/platform/i18n"
3
3
  /** Display text of the http-security codes, Vietnamese and English. */
4
4
  export const HTTP_SECURITY_MESSAGES: MessageBundle = {
5
5
  vi: {
6
- "errors.HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE": "C\u01a1 s\u1edf d\u1eef li\u1ec7u b\u1eaft bu\u1ed9c hi\u1ec7n kh\u00f4ng kh\u1ea3 d\u1ee5ng.",
6
+ "errors.HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE":
7
+ "C\u01a1 s\u1edf d\u1eef li\u1ec7u b\u1eaft bu\u1ed9c hi\u1ec7n kh\u00f4ng kh\u1ea3 d\u1ee5ng.",
7
8
  "errors.HTTP_SECURITY_ORIGIN_REJECTED":
8
9
  "Ngu\u1ed3n g\u1eedi y\u00eau c\u1ea7u kh\u00f4ng \u0111\u01b0\u1ee3c ph\u00e9p.",
9
10
  "errors.HTTP_SECURITY_RATE_LIMITED":