@starci/hfs 5.0.7 → 5.0.8

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 (62) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +7 -8
  3. package/cache/fake-session.mjs +1 -1
  4. package/cache/hfs-family.mjs +22 -9
  5. package/cache/import-graph.d.mts +32 -4
  6. package/cache/import-graph.mjs +143 -11
  7. package/cache/lint-family.d.mts +0 -2
  8. package/cache/lint-family.mjs +54 -33
  9. package/cache/records.d.mts +7 -0
  10. package/cache/records.mjs +17 -1
  11. package/cache/run.d.mts +1 -1
  12. package/cache/session.mjs +6 -2
  13. package/cache/side-key.d.mts +2 -0
  14. package/cache/side-key.mjs +5 -1
  15. package/cache/structure-memo.d.mts +34 -0
  16. package/cache/structure-memo.mjs +150 -0
  17. package/cache/types.d.mts +6 -2
  18. package/cache/wiring-family.mjs +1 -1
  19. package/codemod/small-renames.mjs +22 -2
  20. package/lint/eslint-list.cjs +26 -0
  21. package/lint/run.d.mts +18 -58
  22. package/lint/run.mjs +54 -284
  23. package/package.json +1 -1
  24. package/release/lint-report.d.mts +1 -1
  25. package/release/lint-report.mjs +3 -3
  26. package/report/sonar.d.mts +1 -1
  27. package/report/sonar.mjs +3 -3
  28. package/report/stylelint-findings.d.mts +23 -0
  29. package/report/stylelint-findings.mjs +9 -0
  30. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  31. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  32. package/runtime/knowledge/hfs/rules.yaml +4 -4
  33. package/runtime/knowledge/hfs/slots.yaml +1 -1
  34. package/runtime/knowledge/sonar-gate.yaml +2 -2
  35. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  36. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  37. package/runtime/scripts/hfs/check.d.mts +16 -1
  38. package/runtime/scripts/hfs/check.mjs +18 -7
  39. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  40. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  41. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  42. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  43. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  44. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  46. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  47. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  48. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  49. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  50. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  51. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  52. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  53. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  54. package/scaffold/app.mjs +3 -3
  55. package/src/check-cache-structure-support.d.mts +52 -0
  56. package/src/main.d.mts +3 -2
  57. package/src/main.mjs +123 -109
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +19 -20
  60. package/sync/index.mjs +2 -2
  61. package/templates/app/package-scripts/core.json +2 -2
  62. package/templates/app/task-graph/turbo.json +0 -3
package/src/main.mjs CHANGED
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // The @starci/hfs implementation behind `starci app`: one repository, `<app>/`, with one package.json, lockfile and node_modules
10
10
  // at its root and two sides, be/ and fe/, declared by the one hfs.json of kind app. Every verb runs at the app root.
11
- // starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
11
+ // starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--side be|fe] [--fix] [--out <sonar file>] [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
12
12
  // every tracked path has a slot; required files exist; nothing forbidden or
13
13
  // tracked-that-must-be-ignored; pins match; every managed file equals its render
14
14
  // (sync/managed.mjs, the .gitignore block and sonar-project.properties included); no empty or ghost
@@ -29,17 +29,14 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
29
29
  // progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
30
30
  // on any difference; =full never reads the cache (the release gate).
31
31
  // --coverage-from <lcov> judges a coverage report produced elsewhere (the merge of a sharded unit run) and records nothing.
32
+ // --side be|fe limits the lint gate to one side (a CI matrix runs one per side). --out <file> writes every finding of the run as ONE
33
+ // Sonar Generic Issue Import document (engine ids starci-hfs, eslint, stylelint). --fix runs ESLint and stylelint with --fix before the
34
+ // check and is refused unless --gate lint is selected.
32
35
  // --staged: the pre-commit guard alone (sync/hygiene.mjs: staged .starciwork and .starcistacks paths, the secrets
33
36
  // guard and the bypass guard over every staged file read from the index, a new back-end logic file staged
34
37
  // without its unit spec); it takes no other flag.
35
38
  // Exit 1 at 20 error findings or a hard blocker (gate-threshold.mjs). The root checks run once and
36
39
  // the side checks and the machine once per side, the side folder as their root; every path is app-relative.
37
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--json] [--out <file>]
38
- // the ONE lint entry (npm run lint): ESLint per side with that side's canon config (per-file rules and
39
- // the project-graph rules), this check's findings, and stylelint over the fe side, as one starci/lint@1
40
- // report; --out writes the one Sonar import file. Exit 0 under 20 error findings and no hard blocker, 1 otherwise, 2 a tool could not run (lint/run.mjs).
41
- // --workspace <dir> (from the current directory) scopes the same lint to one fe workspace: the
42
- // `lint` script of fe/apps/<app> and fe/packages/<pkg>, run by the turbo lint task.
43
40
  // starci app scaffold <name> [--cwd <dir>] a new app <dir>/<name>/ (<dir> is the global --cwd): the root (hfs.json, package.json, managed files, .starciwork) and the
44
41
  // be/ and fe/ skeletons (scaffold/app.mjs). Accepts an empty unborn main Git root.
45
42
  // starci app slot <path> [--cwd <dir>] [--json] which slot owns the path, its tier, allowed imports, required tests.
@@ -80,7 +77,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
80
77
  // one front-end unit in the shape-slot pattern from templates/fe/units (scaffold/add-fe-unit.mjs); the
81
78
  // first block writes the app's one copy of the status owners. Never overwrites a unit.
82
79
  // The flags of every verb are those of USAGE below, generated from the parser tables that refuse every other flag.
83
- // Every finding names a why code and carries its Vietnamese text. check, lint and slot read the app, never write to it. Exit codes:
80
+ // Every finding names a why code and carries its Vietnamese text. check and slot read the app, never write to it. Exit codes:
84
81
  // 0 clean, 1 error findings, 2 a refusal or bad usage.
85
82
  import fs from 'node:fs';
86
83
  import { isHostHeld } from '../runtime/scripts/api/node/run-node.mjs';
@@ -96,17 +93,17 @@ import { dbBaselineMain } from '../runtime/scripts/hfs/db-baseline-verb.mjs';
96
93
  import { dbRestampMain } from '../runtime/scripts/hfs/db-restamp-verb.mjs';
97
94
  import { formatCoverageReport } from '../runtime/scripts/hfs/coverage-from.mjs';
98
95
  import { gateVerdict } from '../runtime/scripts/hfs/gate-threshold.mjs';
99
- import { HFS_DECLARATION_FILE, HfsSlotsError, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
96
+ import { HFS_DECLARATION_FILE, HfsSlotsError, SIDES, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
100
97
  import { main as syncMain } from '../sync/cli.mjs';
101
98
  import { runWorkHygiene } from '../sync/hygiene.mjs';
102
- import { SyncError, bundledPresets } from '../sync/index.mjs';
99
+ import { STYLE_GLOB, SyncError, bundledPresets } from '../sync/index.mjs';
103
100
  import { emitContractsAsync } from '../emit/contracts.mjs';
104
101
  import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
105
102
  import { EditionRefusal } from '../scaffold/edition-gate.mjs';
106
103
  import { addKind } from '../scaffold/add.mjs';
107
104
  import { scaffoldApp } from '../scaffold/app.mjs';
108
105
  import { newImages } from '../scaffold/image.mjs';
109
- import { cachedLinters, eslintRepository, lintRepository, parseLintArgs, printLintText, runLinter as nativeLinter } from '../lint/run.mjs';
106
+ import { cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
110
107
  import { runSonarForApp, runTestForApp, runTestShard, runTypesForApp } from '../cache/app-families.mjs';
111
108
  import { CACHE_ENV, createDiskSession } from '../cache/session.mjs';
112
109
  import { DEFAULT_FAMILIES, runAppFamilies } from '../cache/run.mjs';
@@ -115,7 +112,8 @@ import { familyLine } from '../cache/report.mjs';
115
112
  import { writeRunLog } from '../cache/run-log.mjs';
116
113
  import { FAMILIES } from '../cache/types.mjs';
117
114
  import { SECRET_CODES as SECRET_CODE_LIST } from '../cache/hfs-family.mjs';
118
- import { writeReport } from '../report/sonar.mjs';
115
+ import { linterReport, mergeReports, sonarReport, sourceRootsOf, writeReport } from '../report/sonar.mjs';
116
+ import { stylelintFindings } from '../report/stylelint-findings.mjs';
119
117
  import { checkEdition } from '../upgrade/check.mjs';
120
118
  import { UpgradeError, upgradeMain } from '../upgrade/index.mjs';
121
119
  import { ACTIONS as STACK_ACTIONS, FLAGS as STACK_FLAGS, OPERANDS as STACK_OPERANDS, READ_ONLY as STACK_READ_ONLY, REPEAT as STACK_REPEAT, VALUE as STACK_VALUE, stackMain as deployStackMain } from '../stack/cli.mjs';
@@ -139,7 +137,10 @@ const valueFlag = (opts, flag) => { const given = opts[flag]; return typeof give
139
137
  const booleanFlag = (opts, flag) => { const given = opts[flag]; return typeof given === 'boolean' ? given : undefined; };
140
138
  /** A list flag as parse stores it: every value given, else undefined. */
141
139
  const listFlag = (opts, flag) => { const given = opts[flag]; return Array.isArray(given) ? given : undefined; };
142
- export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
140
+ /** An argument the parser refuses (an unknown flag, a missing value, a form a verb does not take): the one error `main` follows with the usage. */
141
+ export class UsageError extends Error {
142
+ }
143
+ export const VERBS = Object.freeze(['scaffold', 'add', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
143
144
  const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['new', dbNewMain], ['drift', dbDriftMain]]);
144
145
  /**
145
146
  * The verbs parsed here, each with the only flags it takes ({ boolean, value, list }); a flag of another verb is refused, never ignored.
@@ -163,7 +164,7 @@ const PARSED = {
163
164
  { operands: 'image', flags: [] },
164
165
  ],
165
166
  },
166
- check: { run: checkMain, value: ['--coverage-from', '--shard'], list: GATE_FLAGS, boolean: ['--json', '--staged', '--no-cache'], edition: true, forms: [{ only: { boolean: ['--json', '--no-cache'], list: GATE_FLAGS, value: ['--coverage-from', '--shard'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
167
+ check: { run: checkMain, value: ['--coverage-from', '--shard', '--side', '--out'], list: GATE_FLAGS, boolean: ['--json', '--staged', '--no-cache', '--fix'], edition: true, forms: [{ only: { boolean: ['--json', '--no-cache', '--fix'], list: GATE_FLAGS, value: ['--coverage-from', '--shard', '--side', '--out'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
167
168
  slot: { run: slotMain, boolean: ['--json'], forms: [{ operands: '<path>' }] },
168
169
  emit: { run: emitMain, forms: [{}] },
169
170
  };
@@ -174,7 +175,7 @@ const PARSED_FLAGS = new Set([...Object.values(PARSED).flatMap(flagsOf), '--edit
174
175
  /** The placeholder of a value flag in the usage; any other prints as <name>. */
175
176
  const PLACEHOLDERS = {
176
177
  '--gate': `<${CHECK_GATES.join('|')}>`,
177
- '--registry-visibility': 'public|private', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
178
+ '--registry-visibility': 'public|private', '--side': 'be|fe', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
178
179
  '--event': '<event>', '--from': '<service>', '--service': '<Class>=<module>', '--owner': '<service>', '--failed': '<event>', '--done': '<event>',
179
180
  '--shapes': '<a,b,c>', '--blocks': '<a,b>', '--tabs': '<a,b>', '--kind': 'modal|drawer', '--image': '<service>=<ref>', '--sha': '<sha>',
180
181
  '--previous': '<release>', '--release': '<release>', '--out': '<path>', '--since': '<ISO>', '--timeout': '<seconds>', '--retry': '<seconds>',
@@ -194,10 +195,9 @@ const specUsage = ({ boolean = [], value = [], list = [] }, placeholders = PLACE
194
195
  /** A dbOptions list (bare names) as a spec; --cwd is the global flag. */
195
196
  const dbSpec = (names) => ({ boolean: names.filter((name) => DB_BOOLEAN_OPTIONS.includes(name)).map((name) => `--${name}`), value: names.filter((name) => name !== 'cwd' && !DB_BOOLEAN_OPTIONS.includes(name)).map((name) => `--${name}`) });
196
197
  const DATABASE_USAGE = [['new', '<kebab-name>', DB_NEW_FLAGS], ['baseline', '', dbSpec(BASELINE_OPTIONS)], ['restamp', '', dbSpec(RESTAMP_OPTIONS)], ['migrate', '', DB_MIGRATE_FLAGS], ['post-cutover', '', DB_POST_CUTOVER_FLAGS], ['dump', '', DB_DUMP_FLAGS], ['drift', '', DB_DRIFT_FLAGS]];
197
- /** The usage of each verb, from the tables its parser refuses every other flag with (lint, codemod and upgrade keep hand-parsed arguments, held by main.spec.mjs). */
198
+ /** The usage of each verb, from the tables its parser refuses every other flag with (codemod and upgrade keep hand-parsed arguments, held by main.spec.mjs). */
198
199
  const VERB_USAGE = {
199
200
  ...Object.fromEntries(Object.entries(PARSED).map(([verb, spec]) => [verb, spec.forms.map((form) => line(`starci app ${verb}`, form.operands, specUsage(form.only ?? (form.flags === undefined ? spec : { value: form.flags })), spec.edition && form.edition !== false ? flagUsage('--edition', { value: true }) : ''))])),
200
- lint: ['starci app lint [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--full] [--json] [--out <file>]'],
201
201
  codemod: CODEMODS.map((name) => `starci app codemod ${name} [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]`),
202
202
  upgrade: ['starci app upgrade [--execute]'],
203
203
  sync: [SYNC_USAGE.sync],
@@ -235,18 +235,18 @@ function parse(verb, argv) {
235
235
  else if (value.includes(flag) || list.includes(flag)) {
236
236
  const given = inline ?? args.next().value;
237
237
  if (!given || given.startsWith('--'))
238
- throw new Error(`${flag} needs a value`);
238
+ throw new UsageError(`${flag} needs a value`);
239
239
  if (list.includes(flag))
240
240
  opts[key] = [...(opts[key] ?? []), given];
241
241
  else if (opts[key] !== undefined)
242
- throw new Error(`${flag} may be given only once`);
242
+ throw new UsageError(`${flag} may be given only once`);
243
243
  else
244
244
  opts[key] = given;
245
245
  }
246
246
  else if (boolean.includes(flag))
247
- throw new Error(`${flag} takes no value`);
247
+ throw new UsageError(`${flag} takes no value`);
248
248
  else
249
- throw new Error(PARSED_FLAGS.has(flag) ? `starci app ${verb} takes no ${flag}` : `unknown flag ${flag}`);
249
+ throw new UsageError(PARSED_FLAGS.has(flag) ? `starci app ${verb} takes no ${flag}` : `unknown flag ${flag}`);
250
250
  }
251
251
  return opts;
252
252
  }
@@ -281,7 +281,7 @@ function printCheckOverview(result, out) {
281
281
  out(` ESLint: ${sides}
282
282
  `);
283
283
  for (const error of result.eslint.errors)
284
- out(` ESLint tool error: ${error}
284
+ out(` ${error.startsWith('stylelint ') ? 'stylelint' : 'ESLint'} tool error: ${error}
285
285
  `);
286
286
  }
287
287
  }
@@ -305,6 +305,9 @@ function printCheck(result, out) {
305
305
  for (const [code, list] of byCode)
306
306
  printFindingGroup(code, list, out);
307
307
  out(`\n${counts.error} error finding${counts.error === 1 ? '' : 's'}, ${counts.warning} warning${counts.warning === 1 ? '' : 's'}, ${counts.info} report-only\n`);
308
+ const counting = Object.entries((result.counting ?? {})).sort(([a], [b]) => a.localeCompare(b));
309
+ if (counting.length)
310
+ out(`counting (warn, not in the verdict): ${counting.map(([rule, count]) => `${rule} ${count}`).join(', ')}\n`);
308
311
  }
309
312
  function printNoSlot(e, out) {
310
313
  out(` no slot owns this path (${e.code}): ${e.titleVi}\n ${e.whyVi}\n`);
@@ -342,7 +345,7 @@ function selectedFamilies(opts, fallback) {
342
345
  return 'sonar';
343
346
  const family = FAMILIES.find((candidate) => candidate === value);
344
347
  if (family === undefined)
345
- throw new Error(`--gate accepts ${CHECK_GATES.join('|')}; got ${JSON.stringify(value)}`);
348
+ throw new UsageError(`--gate accepts ${CHECK_GATES.join('|')}; got ${JSON.stringify(value)}`);
346
349
  return family;
347
350
  });
348
351
  return new Set(given.length > 0 ? given : fallback);
@@ -353,14 +356,11 @@ function openSession({ repoRoot, tracked, io, full, use, planned }) {
353
356
  const env = use === 'bypass' ? { ...io.env, [CACHE_ENV]: 'full' } : use === 'read' ? { ...io.env, [CACHE_ENV]: io.env[CACHE_ENV] === 'full' ? 'full' : 'verify' } : io.env;
354
357
  return createDiskSession({ root: repoRoot, tracked, env, progress, cliVersion: io.harness?.version, full });
355
358
  }
356
- /**
357
- * The app pass of `starci app check` and `starci app lint`: the families the verb selects, against one cache session, merged into one result.
358
- * `only` limits the formatter to those (app-relative) files and bypasses the cache (`app lint --changed`).
359
- */
360
- async function appPass({ repoRoot, harness, only, presets, prettier, io, selected, full, use = 'normal', session: given }) {
359
+ /** The app pass of `starci app check`: the families the verb selects, against one cache session, merged into one result. */
360
+ async function appPass({ repoRoot, harness, presets, prettier, io, selected, full, use = 'normal', session: given }) {
361
361
  const tracked = trackedFiles(repoRoot);
362
- const session = given ?? openSession({ repoRoot, tracked, io, full, use: only === undefined ? use : 'bypass', planned: tracked.length * Math.max(1, selected.size) });
363
- const result = await runAppFamilies({ repoRoot, tracked, session, selected, harness, presets, prettier, only, edition: undefined, execute: io.runHeavyNode });
362
+ const session = given ?? openSession({ repoRoot, tracked, io, full, use, planned: tracked.length * Math.max(1, selected.size) });
363
+ const result = await runAppFamilies({ repoRoot, tracked, session, selected, harness, presets, prettier, edition: undefined, execute: io.runHeavyNode });
364
364
  if (given !== undefined)
365
365
  return result;
366
366
  const flushed = session.flush();
@@ -374,49 +374,6 @@ function verdictFindings(session) {
374
374
  const why = readWhy(skillRoot, [...new Set(verdicts.map((verdict) => verdict.code))]);
375
375
  return withWhy(verdicts.map((verdict) => ({ code: verdict.code, level: verdict.level, path: verdict.path, message: verdict.message })), why);
376
376
  }
377
- /** The nearest folder at or above `dir` that holds hfs.json (the app root of a workspace), or `dir` itself when none does (the lint then refuses it). */
378
- export function appRootAbove(dir) {
379
- for (let at = dir;; at = path.dirname(at)) {
380
- if (fs.existsSync(path.join(at, HFS_DECLARATION_FILE)))
381
- return at;
382
- if (path.dirname(at) === at)
383
- return dir;
384
- }
385
- }
386
- /** `starci app lint`: ESLint per side, the app checks and stylelint over the fe side as one report; see lint/run.mjs. */
387
- async function lintMain(argv, io) {
388
- const { cwd, harness, stdout, presets, prettier, runLinter, checkApp = appPass } = io;
389
- const opts = parseLintArgs(argv);
390
- // --workspace names a folder from the working directory (`starci app lint --workspace .` is an fe workspace script); the app
391
- // root is then the nearest folder above it that holds hfs.json, and the workspace is passed on app-relative.
392
- const workspaceDir = opts.workspace === undefined ? null : path.resolve(cwd, opts.workspace);
393
- const repoRoot = path.resolve(workspaceDir ? appRootAbove(workspaceDir) : cwd);
394
- if (workspaceDir)
395
- opts.workspace = path.relative(repoRoot, workspaceDir).split(path.sep).join('/');
396
- // The lint shares the cache of `app check`: --workspace reads it and writes none, --changed and --fix neither read nor write, --full judges again.
397
- const use = opts.changed !== null || opts.fix ? 'bypass' : opts.workspace !== undefined ? 'read' : 'normal';
398
- const selected = new Set(['hfs', 'secrets', 'db', 'format']);
399
- // One session for the app findings and the linters, so the two never hold two answers; checkApp is a spec seam that brings its own.
400
- const session = io.checkApp === undefined ? openSession({ repoRoot, tracked: trackedFiles(repoRoot), io, full: opts.full === true ? new Set([...selected, 'lint']) : undefined, use, planned: 1 }) : undefined;
401
- const { report, sonar, exit } = await lintRepository({
402
- repoRoot, opts, trackedFiles, runLinter, ...(session === undefined ? {} : { cache: { session } }),
403
- hfsCheck: (root) => checkApp({ repoRoot: root, harness, only: opts.changed ?? undefined, presets, prettier, io, selected, use, session }),
404
- });
405
- const flushed = session?.flush();
406
- const verdicts = session === undefined ? [] : verdictFindings(session);
407
- if (opts.out !== undefined)
408
- writeReport(path.resolve(cwd, opts.out), sonar);
409
- if (opts.format === 'json')
410
- stdout(`${JSON.stringify(report, null, 2)}
411
- `);
412
- else
413
- printLintText(report, stdout);
414
- for (const verdict of verdicts)
415
- stdout(`${verdict.level.toUpperCase()} ${verdict.code}: ${verdict.message}\n`);
416
- for (const warning of flushed?.warnings ?? [])
417
- stdout(`cache: ${warning}\n`);
418
- return Math.max(exit, verdicts.some((verdict) => verdict.level === 'error') ? 1 : 0);
419
- }
420
377
  /**
421
378
  * The jest preset a new app renders its Sonar exclusions from: the copy this @starci/hfs ships (bundledPresets), the same version the
422
379
  * scaffold pins, so `npx starci-harness app scaffold` works before any dependency of the app is installed.
@@ -426,7 +383,7 @@ const scaffoldPresets = () => bundledPresets();
426
383
  function flagValue(flag, arg, args) {
427
384
  const value = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : args.next().value;
428
385
  if (!value || value.startsWith('--'))
429
- throw new Error(`${flag} needs a value`);
386
+ throw new UsageError(`${flag} needs a value`);
430
387
  return value;
431
388
  }
432
389
  /** The flags every verb takes (--cwd, --quiet, --help), taken out of argv before the verb parses the rest. */
@@ -463,7 +420,7 @@ function editionOption(verb, argv) {
463
420
  }
464
421
  edition = flagValue('--edition', arg, args);
465
422
  if (!['full', 'lite'].includes(edition))
466
- throw new Error('--edition accepts full or lite');
423
+ throw new UsageError('--edition accepts full or lite');
467
424
  }
468
425
  return { edition, argv: rest };
469
426
  }
@@ -486,7 +443,7 @@ function databaseMain(argv, io) {
486
443
  const [verb, ...rest] = argv;
487
444
  const dispatch = DATABASE_VERBS.get(verb);
488
445
  if (!dispatch)
489
- throw new Error('starci app db takes new, baseline, restamp, migrate, post-cutover, dump or drift');
446
+ throw new UsageError('starci app db takes new, baseline, restamp, migrate, post-cutover, dump or drift');
490
447
  return dispatch(rest, io);
491
448
  }
492
449
  function printCheckProofs(result, out) {
@@ -496,7 +453,7 @@ function printCheckProofs(result, out) {
496
453
  out(formatCoverageReport(result.coverage));
497
454
  }
498
455
  /** The flags `check --staged` refuses: it is the pre-commit guard alone, so none of the whole-app pass's options means anything with it. */
499
- const STAGED_REFUSES = ['--json', '--no-cache', '--coverage-from', '--shard', ...GATE_FLAGS];
456
+ const STAGED_REFUSES = ['--json', '--no-cache', '--fix', '--coverage-from', '--shard', '--side', '--out', ...GATE_FLAGS];
500
457
  /** The family a finding belongs to, for naming it when a verified run disagrees with the cache. */
501
458
  function familyOf(finding) {
502
459
  const code = finding.code ?? '';
@@ -510,24 +467,39 @@ function familyOf(finding) {
510
467
  return 'wiring';
511
468
  return SECRET_CODE_LIST.has(code) ? 'secrets' : 'hfs';
512
469
  }
513
- /** One run of the selected families against the cache, then the coverage proof on top; returns the result and what it took. */
514
- async function checkRun({ io, selected, full, use, coverageFrom }) {
470
+ /** The linters with --fix before the check judges the tree: ESLint through the shared execution, stylelint over the fe stylesheets. What they still find is the check's to report. */
471
+ async function fixLint({ repoRoot, side, run }) {
472
+ const app = readRepoDeclaration(loadSlotManifest(), repoRoot);
473
+ await eslintRepository({ repoRoot, app, opts: { ...(side === undefined ? {} : { side }), fix: true }, run });
474
+ if (side !== 'be')
475
+ await (run ?? nativeLinter)({ cwd: path.join(repoRoot, 'fe'), pkg: 'stylelint', bin: 'stylelint', args: [STYLE_GLOB, '--formatter', 'json', '--fix'] });
476
+ }
477
+ /** One run of the selected families against the cache, then the coverage proof on top; returns the result, what it took and the raw linter results. */
478
+ async function checkRun({ io, selected, full, use, coverageFrom, side, fix = false }) {
515
479
  const { cwd: repoRoot, harness, edition, presets, prettier, checkApp = appPass, runLinter } = io;
516
480
  const started = Date.now();
481
+ if (fix)
482
+ await fixLint({ repoRoot, side, run: runLinter });
517
483
  const tracked = io.checkApp === undefined ? trackedFiles(repoRoot) : [];
518
484
  const session = io.checkApp === undefined ? openSession({ repoRoot, tracked, io, full, use, planned: tracked.length * Math.max(1, selected.size) }) : undefined;
519
485
  const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, harness, presets, prettier, io, selected, full, use, session }), presets, prettier });
520
486
  const proof = withCheckProofs(checked, { repoRoot, edition, wiring: false, coverageFrom });
521
487
  const extra = [];
522
488
  const lines = [];
489
+ const raw = { eslint: [], stylelint: [] };
523
490
  let eslint = null;
491
+ let style = { results: [], errors: [] };
524
492
  if (selected.has('lint')) {
525
493
  if (session !== undefined) {
526
494
  const app = readRepoDeclaration(loadSlotManifest(), repoRoot);
527
- eslint = (await cachedLinters({ repoRoot, app, opts: { changed: null, fix: false, format: 'text', out: undefined, workspace: undefined }, workspace: null, onFe: null, run: runLinter, cache: { session } })).eslint;
495
+ const linted = await cachedLinters({ repoRoot, app, side, run: runLinter, session });
496
+ eslint = linted.eslint;
497
+ style = linted.stylelint;
528
498
  }
529
499
  else
530
- eslint = await eslintRepository({ repoRoot, changed: null, existing: [], run: runLinter });
500
+ eslint = await eslintRepository({ repoRoot, opts: side === undefined ? {} : { side }, changed: null, existing: [], run: runLinter });
501
+ raw.eslint = eslint.raw;
502
+ raw.stylelint = style.results;
531
503
  }
532
504
  if (session !== undefined && selected.has('types'))
533
505
  extra.push(...(await runTypesForApp(session, { repoRoot, tracked, env: io.env, execute: io.runHeavyNode })).findings);
@@ -540,18 +512,46 @@ async function checkRun({ io, selected, full, use, coverageFrom }) {
540
512
  const flushed = session?.flush();
541
513
  const verdicts = session === undefined ? [] : verdictFindings(session);
542
514
  const reports = session === undefined ? [...(checked.families ?? [])] : [...session.reports];
543
- const findings = [...proof.findings, ...extra, ...verdicts, ...(eslint?.findings ?? []).map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: 'error' }))];
515
+ // A rule of the canon's counting cohort is a warning: listed and counted, never part of the verdict.
516
+ const linterFindings = [...(eslint?.findings ?? []).map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: finding.severity === 'warning' ? 'warning' : 'error' })), ...stylelintFindings(style.results, repoRoot)];
517
+ const findings = [...proof.findings, ...extra, ...verdicts, ...linterFindings];
544
518
  // Green under the threshold (gate-threshold.mjs): every finding is still listed, only the verdict changes.
545
519
  const verdict = gateVerdict(findings.filter(finding => finding.level === 'error'));
546
- const toolErrors = eslint?.errors ?? [];
520
+ const toolErrors = [...(eslint?.errors ?? []), ...style.errors];
547
521
  const counts = { ...proof.counts, error: findings.filter(finding => finding.level === 'error').length, warning: findings.filter(finding => finding.level === 'warning').length };
548
- const result = { ...proof, findings, families: reports, predictions: lines, cacheWarnings: [...(checked.cacheWarnings ?? []), ...(flushed?.warnings ?? [])], counts, ...(eslint ? { eslint: { ...eslint.engine, errors: eslint.errors } } : {}), ok: verdict.pass && toolErrors.length === 0, gate: verdict };
549
- return { result, seconds: (Date.now() - started) / 1000 };
522
+ const counting = {};
523
+ for (const finding of linterFindings)
524
+ if (finding.level === 'warning')
525
+ counting[finding.rule] = (counting[finding.rule] ?? 0) + 1;
526
+ const result = { ...proof, findings, families: reports, predictions: lines, counting, cacheWarnings: [...(checked.cacheWarnings ?? []), ...(flushed?.warnings ?? [])], counts, ...(eslint ? { eslint: { ...eslint.engine, errors: toolErrors } } : {}), ok: verdict.pass && toolErrors.length === 0, gate: verdict };
527
+ return { result, seconds: (Date.now() - started) / 1000, raw };
528
+ }
529
+ /** `--out`: every finding the run produced as ONE Sonar Generic Issue Import document (the check findings under starci-hfs, then eslint and stylelint), placed on the `sonar.sources` roots of the side. */
530
+ function writeSonarImport({ repoRoot, file, findings, raw, side }) {
531
+ let properties = '';
532
+ try {
533
+ properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8');
534
+ }
535
+ catch { /* no properties: every finding keeps its own path */ }
536
+ const onSide = (candidate) => {
537
+ if (side === undefined)
538
+ return true;
539
+ const owner = SIDES.find((each) => candidate === each || candidate.startsWith(`${each}/`));
540
+ return (owner ?? 'be') === side;
541
+ };
542
+ const sourceRoots = sourceRootsOf(properties).filter(onSide);
543
+ const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
544
+ const own = findings.filter(finding => finding.engine === undefined && finding.level === 'error' && onSide(String(finding.path ?? '').replace(/\\/g, '/')));
545
+ writeReport(file, mergeReports([
546
+ sonarReport(own, { sourceRoots, tracked }),
547
+ linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
548
+ linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
549
+ ]));
550
550
  }
551
551
  /** STARCI_APP_CHECK_CACHE=verify: the diff-only run, then the full run, compared finding by finding; any difference names family and file (exit 2). */
552
- async function verifyCache(io, selected, coverageFrom) {
553
- const diff = await checkRun({ io, selected, full: undefined, use: 'read', coverageFrom });
554
- const full = await checkRun({ io, selected, full: new Set(selected), use: 'bypass', coverageFrom });
552
+ async function verifyCache(io, selected, coverageFrom, side) {
553
+ const diff = await checkRun({ io, selected, full: undefined, use: 'read', coverageFrom, side });
554
+ const full = await checkRun({ io, selected, full: new Set(selected), use: 'bypass', coverageFrom, side });
555
555
  const keyed = (result) => new Map(result.findings.map((finding) => [`${finding.code}|${finding.path ?? ''}|${finding.message}`, `${familyOf(finding)}: ${finding.path ?? finding.code}`]));
556
556
  const left = keyed(diff.result);
557
557
  const right = keyed(full.result);
@@ -571,33 +571,44 @@ async function verifyCache(io, selected, coverageFrom) {
571
571
  async function checkMain(opts, io) {
572
572
  const { cwd: repoRoot, edition, stdout: out } = io;
573
573
  if (opts.positional.length)
574
- throw new Error('starci app check takes no path');
574
+ throw new UsageError('starci app check takes no path');
575
575
  if (opts.staged === true) {
576
576
  const given = [...STAGED_REFUSES.filter((flag) => opts[flag.slice(2)] !== undefined), ...(edition === undefined ? [] : ['--edition'])];
577
577
  if (given.length)
578
- throw new Error(`starci app check --staged is the pre-commit guard alone and takes no ${given.join(', ')}`);
578
+ throw new UsageError(`starci app check --staged is the pre-commit guard alone and takes no ${given.join(', ')}`);
579
579
  return runWorkHygiene({ cwd: repoRoot, out: (text) => out(`${text}\n`) });
580
580
  }
581
581
  const coverageFrom = valueFlag(opts, 'coverage-from');
582
582
  const shard = valueFlag(opts, 'shard');
583
+ const side = valueFlag(opts, 'side');
584
+ const sonarOut = valueFlag(opts, 'out');
585
+ const fix = booleanFlag(opts, 'fix') === true;
583
586
  const explicit = selectedFamilies(opts, []);
587
+ if (side !== undefined && !['be', 'fe'].includes(side))
588
+ throw new UsageError('LINT_SIDE_INVALID: --side is be or fe');
589
+ if (fix && !explicit.has('lint'))
590
+ throw new UsageError('--fix runs the linters and needs --gate lint');
584
591
  if (shard !== undefined) {
592
+ if (side !== undefined || sonarOut !== undefined)
593
+ throw new UsageError('--shard <i>/<n> takes no --side or --out');
585
594
  // A selected test shard is always a whole-unit shard, in the shape the coverage merge reads; nothing is recorded.
586
595
  const match = /^(\d+)\/(\d+)$/.exec(shard);
587
596
  if (explicit.size !== 1 || !explicit.has('test') || coverageFrom !== undefined)
588
- throw new Error('--shard <i>/<n> requires --gate test alone and takes no --coverage-from');
597
+ throw new UsageError('--shard <i>/<n> requires --gate test alone and takes no --coverage-from');
589
598
  const index = Number(match?.[1]);
590
599
  const total = Number(match?.[2]);
591
600
  if (match === null || !Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 1 || total < index)
592
- throw new Error('--shard needs <i>/<n> with 1 <= i <= n');
601
+ throw new UsageError('--shard needs <i>/<n> with 1 <= i <= n');
593
602
  return runTestShard(repoRoot, index, total, io.runHeavyNode);
594
603
  }
595
604
  // A coverage report judged on its own (the merge job of a sharded run) runs no family; with selectors it comes on top of them.
596
605
  const selected = explicit.size > 0 ? explicit : coverageFrom === undefined ? new Set(DEFAULT_FAMILIES) : new Set();
597
606
  const full = opts['no-cache'] === true ? new Set(selected) : undefined;
598
607
  if (io.env[CACHE_ENV] === 'verify')
599
- return verifyCache(io, selected, coverageFrom);
600
- const { result, seconds } = await checkRun({ io, selected, full, use: coverageFrom !== undefined && explicit.size === 0 ? 'bypass' : 'normal', coverageFrom });
608
+ return verifyCache(io, selected, coverageFrom, side);
609
+ const { result, seconds, raw } = await checkRun({ io, selected, full, use: coverageFrom !== undefined && explicit.size === 0 ? 'bypass' : 'normal', coverageFrom, side, fix });
610
+ if (sonarOut !== undefined)
611
+ writeSonarImport({ repoRoot, file: path.resolve(repoRoot, sonarOut), findings: result.findings, raw, side });
601
612
  if (opts.json)
602
613
  out(`${JSON.stringify(result, null, 2)}\n`);
603
614
  else {
@@ -607,12 +618,12 @@ async function checkMain(opts, io) {
607
618
  out(`${result.gate.line}\n`);
608
619
  }
609
620
  const exit = result.eslint?.errors.length ? 2 : result.ok ? 0 : 1;
610
- writeRunLog(repoRoot, { command: `check${[...new Set(listFlag(opts, 'gate') ?? [])].map((gate) => ` --gate ${gate}`).join('')}${opts['no-cache'] === true ? ' --no-cache' : ''}${coverageFrom === undefined ? '' : ` --coverage-from ${JSON.stringify(coverageFrom)}`}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
621
+ writeRunLog(repoRoot, { command: `check${[...new Set(listFlag(opts, 'gate') ?? [])].map((gate) => ` --gate ${gate}`).join('')}${side === undefined ? '' : ` --side ${side}`}${fix ? ' --fix' : ''}${opts['no-cache'] === true ? ' --no-cache' : ''}${coverageFrom === undefined ? '' : ` --coverage-from ${JSON.stringify(coverageFrom)}`}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
611
622
  return exit;
612
623
  }
613
624
  async function emitMain(opts, { cwd: repoRoot, stdout: out, runHeavyNode }) {
614
625
  if (opts.positional.length)
615
- throw new Error('starci app emit takes no path');
626
+ throw new UsageError('starci app emit takes no path');
616
627
  const be = readRepoDeclaration(loadSlotManifest(), repoRoot).sides?.be;
617
628
  if (!be)
618
629
  throw new Error('starci app emit runs at the app root (the folder of hfs.json)');
@@ -636,9 +647,9 @@ function addFileNoun(noun, opts, repoRoot, out) {
636
647
  const [first = '', second = ''] = args;
637
648
  const foreign = ADD_FLAGS_ALL.filter((flag) => opts[flag.slice(2)] !== undefined);
638
649
  if (foreign.length)
639
- throw new Error(`starci app add ${noun} takes no ${foreign.join(', ')}`);
650
+ throw new UsageError(`starci app add ${noun} takes no ${foreign.join(', ')}`);
640
651
  if (noun !== 'service' && opts.inject !== undefined)
641
- throw new Error(`starci app add ${noun} takes no --inject; only add service does`);
652
+ throw new UsageError(`starci app add ${noun} takes no --inject; only add service does`);
642
653
  let written;
643
654
  if (noun === 'service' && args.length === 2)
644
655
  written = newService({ repoRoot, dir: first, name: second, inject: listFlag(opts, 'inject') ?? [] });
@@ -647,7 +658,7 @@ function addFileNoun(noun, opts, repoRoot, out) {
647
658
  else if (noun === 'image' && args.length === 0)
648
659
  written = newImages({ repoRoot });
649
660
  else
650
- throw new Error('starci app add takes `service <dir> <name> [--inject ...]`, `spec <file>.service.ts` or `image`');
661
+ throw new UsageError('starci app add takes `service <dir> <name> [--inject ...]`, `spec <file>.service.ts` or `image`');
651
662
  for (const file of written)
652
663
  out(`created ${file}\n`);
653
664
  return 0;
@@ -657,9 +668,9 @@ function addMain(opts, { cwd: repoRoot, stdout: out }) {
657
668
  if (noun !== undefined && ADD_FILE_NOUNS.includes(noun))
658
669
  return addFileNoun(noun, opts, repoRoot, out);
659
670
  if (opts.inject !== undefined)
660
- throw new Error(`starci app add ${noun ?? '<noun>'} takes no --inject; only add service does`);
671
+ throw new UsageError(`starci app add ${noun ?? '<noun>'} takes no --inject; only add service does`);
661
672
  if (!noun || !name || extra.length)
662
- throw new Error('starci app add takes `<noun> <name>` and the options of the noun');
673
+ throw new UsageError('starci app add takes `<noun> <name>` and the options of the noun');
663
674
  const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: booleanFlag(opts, 'fe'), shapes: valueFlag(opts, 'shapes'), read: valueFlag(opts, 'read'), app: valueFlag(opts, 'app'), kind: valueFlag(opts, 'kind'), form: booleanFlag(opts, 'form'), blocks: valueFlag(opts, 'blocks'), tabs: valueFlag(opts, 'tabs') } });
664
675
  for (const file of created)
665
676
  out(`created ${file}\n`);
@@ -673,14 +684,14 @@ async function scaffoldMain(opts, { cwd, harness, edition = 'full', presets, loc
673
684
  const [name, ...extra] = opts.positional;
674
685
  const registryVisibility = valueFlag(opts, 'registry-visibility');
675
686
  if (!name || extra.length)
676
- throw new Error(`starci app scaffold takes \`${VERB_USAGE.scaffold[0].slice('starci app scaffold '.length)}\``);
687
+ throw new UsageError(`starci app scaffold takes \`${VERB_USAGE.scaffold[0].slice('starci app scaffold '.length)}\``);
677
688
  const { root: created, files } = scaffoldApp({ name, into: path.resolve(cwd), harness, edition, presets: presets ?? scaffoldPresets(), ...(registryVisibility === undefined ? {} : { registryVisibility }), ...(lock ? { lock } : {}) });
678
- out(`starci app scaffold: created ${created} (${files.length} files); next: npm ci (the lockfile, then the git hooks through npm run prepare; after npm ci --ignore-scripts run npm run prepare), then starci app lint\n`);
689
+ out(`starci app scaffold: created ${created} (${files.length} files); next: npm ci (the lockfile, then the git hooks through npm run prepare; after npm ci --ignore-scripts run npm run prepare), then npm run lint, which is starci app check --gate lint\n`);
679
690
  return 0;
680
691
  }
681
692
  function slotMain(opts, { cwd: repoRoot, stdout: out }) {
682
693
  if (opts.positional.length !== 1)
683
- throw new Error('starci app slot takes exactly one path');
694
+ throw new UsageError('starci app slot takes exactly one path');
684
695
  const [input = ''] = opts.positional;
685
696
  const explained = explainPath({ repoRoot, input });
686
697
  if (opts.json)
@@ -710,13 +721,13 @@ const WORLD_ACTIONS = ['up', 'down', 'status'];
710
721
  /** `starci app world <up|down|status>`: the local test world. */
711
722
  async function worldAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err }) {
712
723
  if (!WORLD_ACTIONS.includes(argv[0]))
713
- throw new Error(`starci app world takes ${WORLD_ACTIONS.join(', ')}`);
724
+ throw new UsageError(`starci app world takes ${WORLD_ACTIONS.join(', ')}`);
714
725
  if ((edition ?? declaredEdition(cwd)) === 'lite')
715
726
  throw new Error('starci app world up|down|status manage the local test world of the full edition; a lite app starts its local database with npm run db:start');
716
727
  const run = stackMain ?? await installedStackMain(cwd);
717
728
  return run(argv, { cwd, out, err });
718
729
  }
719
- const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['world', worldAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)], ['codemod', codemodMain]]);
730
+ const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['world', worldAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)], ['codemod', codemodMain]]);
720
731
  function dispatchMain(verb, argv, io) {
721
732
  if (verb === 'sync')
722
733
  return syncMain([verb, ...argv], io);
@@ -725,12 +736,15 @@ function dispatchMain(verb, argv, io) {
725
736
  return direct(argv, io);
726
737
  return PARSED[verb].run(parse(verb, argv), io);
727
738
  }
739
+ /** What `main` prints for a thrown error: the usage only after a usage error; a refusal or a tool failure prints its code and message alone. */
728
740
  function errorMessage(error) {
729
741
  if (error instanceof EditionRefusal || error instanceof UpgradeError || error instanceof HfsSlotsError || error instanceof SyncError)
730
742
  return `${error.message}\n`;
731
743
  if (error instanceof ScaffoldError)
732
744
  return `${error.code}: ${error.message}\n`;
733
- return `starci app: ${error.message}\n${USAGE}`;
745
+ if (error instanceof UsageError)
746
+ return `starci app: ${error.message}\n${USAGE}`;
747
+ return `starci app: ${error.message}\n`;
734
748
  }
735
749
  /**
736
750
  * `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
@@ -742,8 +756,8 @@ export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout
742
756
  try {
743
757
  const global = globalOptions(argv, path.resolve(baseCwd));
744
758
  const [verb, ...given] = global.argv;
745
- // --quiet silences stdout for every verb but check and lint, whose diagnosis it keeps (their progress on stderr is what it mutes).
746
- const out = global.quiet && !['check', 'lint'].includes(verb) ? () => { } : stdout;
759
+ // --quiet silences stdout for every verb but check, whose diagnosis it keeps (its progress on stderr is what it mutes).
760
+ const out = global.quiet && verb !== 'check' ? () => { } : stdout;
747
761
  if (global.help) {
748
762
  out(USAGE);
749
763
  return 0;
@@ -11,8 +11,9 @@ interface CiSource {
11
11
  /** jest --shard=i/N gives each shard ceil(specs / N) spec files in order, so shard N is empty unless (N - 1) * ceil(specs / N) < specs. */
12
12
  export declare function filledShards(specs: number, wanted: number): number;
13
13
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
14
- export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app }?: {
14
+ export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app, packages }?: {
15
15
  nodeMajor?: string;
16
+ packages?: boolean;
16
17
  app?: {
17
18
  sides: {
18
19
  fe: {
@@ -179,7 +179,7 @@ function ciJobSettings(declaration, source) {
179
179
  };
180
180
  }
181
181
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
182
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
182
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app, packages = false } = {}) {
183
183
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
184
184
  const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
185
185
  const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
@@ -195,8 +195,6 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
195
195
  shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
196
196
  ciBuildEnv: build.env,
197
197
  ciBuildSteps: build.steps,
198
- ciLint: exampleCommand(source, 'npm run lint'),
199
- ciLintSonar: exampleCommand(source, 'npm run lint -- --out reports/lint.sonar.json'),
200
198
  // The coverage judgement alone over the merged report: the fast and lint jobs already ran every other rule of the app check.
201
199
  ciCoverageProof: exampleCommand(source, 'npx starci app coverage verify ' + lcovReport),
202
200
  // An app with an entity list also checks its entities against the schema its migrations build, in the build job (a runner with Docker).
@@ -205,23 +203,24 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
205
203
  sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
206
204
  sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
207
205
  // One lint job per side (the lint matrix): with Sonar each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
208
- ciLintSteps: (sonar ? [
209
- ' - name: lint with Sonar import',
210
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --out reports/lint.' + side + '.sonar.json')}`,
211
- ' env:',
212
- ` NODE_OPTIONS: ${settings.nodeOptions}`,
213
- ` - uses: ${UPLOAD_ARTIFACT}`,
214
- // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
215
- " if: always() && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
216
- ' with:',
217
- ` name: sonar-lint-${side}`,
218
- ` path: reports/lint.${side}.sonar.json`,
219
- ] : [
220
- ' - name: lint',
221
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
222
- ' env:',
223
- ` NODE_OPTIONS: ${settings.nodeOptions}`,
224
- ]).join('\n'),
206
+ // The typed lint reads the generated code and the types the fe packages declare (their dist), so both are built first, the way the root lint script does.
207
+ ciLintSteps: [' - name: build the lint inputs', ` run: ${exampleCommand(source, packages ? 'npm run codegen --silent && npx --no-install turbo run build --filter=./fe/packages/*' : 'npm run codegen --silent')}`, ...(sonar ? [
208
+ ' - name: lint with Sonar import',
209
+ ` run: ${exampleCommand(source, 'npx --no-install starci app check --gate lint --side ' + side + ' --out reports/lint.' + side + '.sonar.json')}`,
210
+ ' env:',
211
+ ` NODE_OPTIONS: ${settings.nodeOptions}`,
212
+ ` - uses: ${UPLOAD_ARTIFACT}`,
213
+ // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
214
+ " if: always() && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
215
+ ' with:',
216
+ ` name: sonar-lint-${side}`,
217
+ ` path: reports/lint.${side}.sonar.json`,
218
+ ] : [
219
+ ' - name: lint',
220
+ ` run: ${exampleCommand(source, 'npx --no-install starci app check --gate lint --side ' + side)}`,
221
+ ' env:',
222
+ ` NODE_OPTIONS: ${settings.nodeOptions}`,
223
+ ])].join('\n'),
225
224
  codecovSteps: codecovSteps(lcovReport, codecov),
226
225
  verdictSonarNeed: sonar ? ', sonar' : '',
227
226
  verdictSonarCheck: sonar ? `\n if [[ "$FULL" == true && "$CHECKS" == true ]]; then expect sonar success; else expect sonar idle; fi` : '',