@starci/hfs 4.12.2 → 4.13.2

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 (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/coverage/cli.mjs CHANGED
@@ -3,13 +3,18 @@
3
3
  // coverage proof, the Codecov upload and the Sonar import read. The merge is the app's installed @starci/jest-preset (`@starci/jest-preset/merge`):
4
4
  // istanbul-lib-coverage merges the shard maps by source location, and the preset's own lcov reporter writes the result exactly as an unsharded run
5
5
  // would. A path is a coverage-final.json or a directory searched for every one below it; no report found refuses (exit 2), never an empty report.
6
+ // `--records <dir>` merges instead the committed per-spec records of the check cache (`.starcistacks/cache/test-coverage`): the union of what every spec covers,
7
+ // which is the total of a full unit run, written as the same lcov.
6
8
  // `starci app coverage verify <lcov>`: the coverage proof alone (scripts/hfs/coverage-from.mjs, the judgement `starci app check --coverage-from`
7
9
  // adds to the whole check): every measured logic file is in the merged lcov at 100 per file. The managed CI's coverage job runs it after the merge,
8
10
  // so the job re-runs none of the rules the fast and lint jobs already judged. Exit 0 proved, 1 refused (the report lists every finding), 2 bad usage.
9
11
  import path from 'node:path';
10
12
  import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
11
14
  import { coverageFrom, formatCoverageReport } from '../runtime/scripts/hfs/coverage-from.mjs';
12
- export const USAGE = 'usage: starci app coverage merge --out <lcov> <coverage-final.json-or-dir>... [--cwd <dir>] | starci app coverage verify <lcov> [--cwd <dir>]';
15
+ import { parseRecords } from '../cache/jsonl.mjs';
16
+ import { SPEC_STEP, installedSpecMerge, sourcesOf } from '../cache/test-family.mjs';
17
+ export const USAGE = 'usage: starci app coverage merge --out <lcov> (<coverage-final.json-or-dir>... | --records <test-coverage-dir>) [--cwd <dir>] | starci app coverage verify <lcov> [--cwd <dir>]';
13
18
  function parse(argv) {
14
19
  const [action, ...rest] = argv;
15
20
  if (action !== 'merge' && action !== 'verify')
@@ -17,7 +22,7 @@ function parse(argv) {
17
22
  const opts = { action, inputs: [] };
18
23
  for (let index = 0; index < rest.length; index += 1) {
19
24
  const arg = rest[index];
20
- if (arg === '--out' || arg === '--cwd') {
25
+ if (arg === '--out' || arg === '--cwd' || arg === '--records') {
21
26
  const value = rest[index + 1];
22
27
  if (!value || value.startsWith('--'))
23
28
  throw new Error(`${arg} needs a value; ${USAGE}`);
@@ -30,11 +35,11 @@ function parse(argv) {
30
35
  opts.inputs.push(arg);
31
36
  }
32
37
  if (action === 'verify') {
33
- if (opts.out !== undefined || opts.inputs.length !== 1)
38
+ if (opts.out !== undefined || opts.records !== undefined || opts.inputs.length !== 1)
34
39
  throw new Error(USAGE);
35
40
  return opts;
36
41
  }
37
- if (!opts.out || !opts.inputs.length)
42
+ if (!opts.out || (opts.records === undefined) === (opts.inputs.length === 0))
38
43
  throw new Error(USAGE);
39
44
  if (path.basename(opts.out) !== 'lcov.info')
40
45
  throw new Error(`--out names the lcov.info the coverage proof reads; ${USAGE}`);
@@ -49,6 +54,28 @@ function installedMerge(cwd) {
49
54
  throw new Error(`@starci/jest-preset/merge is not installed under ${cwd} (${error.code ?? error.message}); install the canon @starci/jest-preset`);
50
55
  }
51
56
  }
57
+ /** Every `*.jsonl` file below `dir`, sorted: the area files of the check cache's `test-coverage` folder. */
58
+ function recordFilesUnder(dir) {
59
+ return fs.readdirSync(dir, { recursive: true, withFileTypes: true })
60
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
61
+ .map((entry) => path.join(entry.parentPath, entry.name))
62
+ .sort();
63
+ }
64
+ /**
65
+ * The committed per-spec coverage records of `dir` (`.starcistacks/cache/test-coverage`) merged into the lcov at `out`: the union of what every spec covers, the total
66
+ * a full unit run reports. The lcov lists sources relative to the folder that holds `coverage/` (the side), the records carry them relative to the app.
67
+ */
68
+ function mergeRecords(cwd, dir, out, merge) {
69
+ const absolute = path.resolve(cwd, dir);
70
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory())
71
+ throw new Error(`no ${dir} folder: the check cache holds no unit coverage yet (run starci app check --test)`);
72
+ const records = recordFilesUnder(absolute).flatMap((file) => parseRecords(fs.readFileSync(file, 'utf8')).records).filter((record) => record.step === SPEC_STEP);
73
+ if (records.length === 0)
74
+ throw new Error(`no unit-spec record under ${dir}: the check cache holds no unit coverage yet (run starci app check --test)`);
75
+ const outDir = path.dirname(path.resolve(cwd, out));
76
+ merge(records.map(sourcesOf), { outDir, reportRoot: path.dirname(outDir), recordRoot: cwd });
77
+ return { specs: records.length };
78
+ }
52
79
  /** The coverage proof of the app at `cwd` over the lcov `report`: exit 0 proved, 1 refused, 2 when the proof cannot run. */
53
80
  function verifyCoverage(cwd, report, { stdout, stderr, prove }) {
54
81
  try {
@@ -65,7 +92,7 @@ function verifyCoverage(cwd, report, { stdout, stderr, prove }) {
65
92
  * Exit 0 merged or proved, 1 a refused proof, 2 bad usage, no preset or no report found. `merge` replaces the installed preset merge and
66
93
  * `prove` the coverage proof in a spec.
67
94
  */
68
- export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), merge, prove = coverageFrom } = {}) {
95
+ export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), merge, mergeSpecs, prove = coverageFrom } = {}) {
69
96
  let opts;
70
97
  try {
71
98
  opts = parse(argv);
@@ -77,6 +104,18 @@ export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout
77
104
  const cwd = path.resolve(baseCwd, opts.cwd ?? '.');
78
105
  if (opts.action === 'verify')
79
106
  return verifyCoverage(cwd, opts.inputs[0], { stdout, stderr, prove });
107
+ if (opts.records !== undefined) {
108
+ try {
109
+ const result = mergeRecords(cwd, opts.records, opts.out, mergeSpecs ?? installedSpecMerge(cwd));
110
+ stdout(`starci app coverage merge: ${result.specs} spec record(s) -> ${path.relative(cwd, path.resolve(cwd, opts.out)).replaceAll('\\', '/')}\n`);
111
+ return 0;
112
+ }
113
+ catch (error) {
114
+ stderr(`starci app coverage: ${error.message}
115
+ `);
116
+ return 2;
117
+ }
118
+ }
80
119
  try {
81
120
  const run = merge ?? installedMerge(cwd);
82
121
  const outDir = path.dirname(path.resolve(cwd, opts.out));
package/db/migrate.d.mts CHANGED
@@ -24,7 +24,15 @@ export interface DatabaseDecl {
24
24
  heldBack: string[];
25
25
  restamp: RestampPair[];
26
26
  postCutover: PostCutover | null;
27
+ /** Where the database runs: `stack` is a service of the environment's own stack (the deploy reaches it inside its container), `external` is outside it (the deploy keeps `--db-url-env`). */
28
+ target: DatabaseTarget;
29
+ /** The stack service of a `stack` database. */
30
+ service: string;
27
31
  }
32
+ /** Where the database of an app runs (see DatabaseDecl.target). */
33
+ export type DatabaseTarget = 'stack' | 'external';
34
+ /** Which migrations a pass applies: all of them, the expand ones a deploy applies before the services update, or the contract ones it applies after accept. */
35
+ export type MigrationPhase = 'all' | 'expand' | 'contract';
28
36
  /** An archived migration: its file name under the restamped version, and that version. */
29
37
  interface ArchiveRow {
30
38
  name: string;
@@ -70,6 +78,13 @@ interface MigrationPlan {
70
78
  export declare function loadDatabase(root: string): DatabaseDecl;
71
79
  /** Classifies the migration files of the app: baseline pair, held-back, forward, restamp pairs and archive. Refuses an inconsistent repository. */
72
80
  export declare function buildModel(root: string, decl: DatabaseDecl): MigrationModel;
81
+ /** True for a contract migration, a file named `<stamp>_..contract_...sql` (BE-PERSISTENCE-19): destructive DDL that runs only after the release is accepted. */
82
+ export declare const isContractMigration: (file: string) => boolean;
83
+ /**
84
+ * The model one phase of a deploy works with. `expand` leaves the contract migrations out of the forward set (they are not pending yet, and a
85
+ * history that already holds them is still explained); `contract` keeps only them. The baseline stays in both: it was applied before.
86
+ */
87
+ export declare function phaseModel(model: MigrationModel, phase: MigrationPhase): MigrationModel;
73
88
  /** The database of `--db-url-env` (default DATABASE_URL). */
74
89
  export declare const resolveTarget: (ctx: VerbContext, options?: {
75
90
  target?: DbTarget;
@@ -94,6 +109,7 @@ export declare function migrateDatabase(ctx: VerbContext, decl: DatabaseDecl, op
94
109
  execute?: boolean;
95
110
  urlEnv?: string | undefined;
96
111
  target?: DbTarget;
112
+ phase?: MigrationPhase;
97
113
  }): Promise<VerbResult>;
98
114
  /** The flags of `starci app db migrate`; the parser refuses every other. */
99
115
  export declare const SPEC: FlagSpec;
package/db/migrate.mjs CHANGED
@@ -30,6 +30,7 @@ const matches = (pattern) => (value) => typeof value === 'string' && pattern.tes
30
30
  const listOf = (item) => (value) => Array.isArray(value) && value.every((entry) => item(entry));
31
31
  const dirOk = (value) => typeof value === 'string' && value.split('/').every((part) => SEGMENT.test(part) && part !== '..');
32
32
  const pairOk = (value) => plain(value) && Object.keys(value).length === 2 && matches(STAMP)(value.from) && matches(STAMP)(value.to);
33
+ const SERVICE = /^[a-z][a-z0-9_-]*$/u;
33
34
  const POST_FIELDS = ['schema', 'functionPrefixes', 'functionNames', 'functions', 'minAgeHours'];
34
35
  const POST_CHECKS = {
35
36
  schema: matches(IDENT), functionPrefixes: listOf(matches(IDENT)), functionNames: listOf(matches(IDENT)),
@@ -44,8 +45,10 @@ const CHECKS = {
44
45
  heldBack: [listOf(matches(NAME)), 'must be a list of migration names'],
45
46
  restamp: [listOf(pairOk), 'must be a list of { from, to } fourteen-digit stamps'],
46
47
  postCutover: [postOk, `must use only ${POST_FIELDS.join(', ')} with valid values`],
48
+ target: [(value) => value === 'stack' || value === 'external', 'must be stack (a service of the environment stack) or external'],
49
+ service: [matches(SERVICE), `must match ${SERVICE} (the stack service that runs the database)`],
47
50
  };
48
- const DEFAULTS = Object.freeze({ migrationsDir: MIGRATIONS_DIR, archiveDir: null, baseline: null, standard: null, heldBack: [], restamp: [], postCutover: null });
51
+ const DEFAULTS = Object.freeze({ migrationsDir: MIGRATIONS_DIR, archiveDir: null, baseline: null, standard: null, heldBack: [], restamp: [], postCutover: null, target: 'stack', service: 'db' });
49
52
  /** Judges the `database:` block. Returns { config } or { problems }; every field is optional (`standard` needs `baseline`). */
50
53
  function normalizeDatabase(block) {
51
54
  if (!plain(block))
@@ -136,6 +139,21 @@ export function buildModel(root, decl) {
136
139
  const base = baseline === null ? [] : [baseline];
137
140
  return { ...model, pushVersions: new Set([...base, ...model.pushPending]), allowed: new Set([...base, ...model.pushPending, ...model.heldVersions]) };
138
141
  }
142
+ const fileOf = (model, version) => model.files.find((file) => file.startsWith(`${version}_`));
143
+ /* ---- phases: expand before the services update, contract after accept */
144
+ /** True for a contract migration, a file named `<stamp>_..contract_...sql` (BE-PERSISTENCE-19): destructive DDL that runs only after the release is accepted. */
145
+ export const isContractMigration = (file) => /^\d{14}_(?:[A-Za-z0-9-]+_)?contract_[A-Za-z0-9_-]+\.sql$/u.test(file);
146
+ /**
147
+ * The model one phase of a deploy works with. `expand` leaves the contract migrations out of the forward set (they are not pending yet, and a
148
+ * history that already holds them is still explained); `contract` keeps only them. The baseline stays in both: it was applied before.
149
+ */
150
+ export function phaseModel(model, phase) {
151
+ if (phase === 'all')
152
+ return model;
153
+ const keep = (version) => isContractMigration(fileOf(model, version)) === (phase === 'contract');
154
+ const pushPending = model.pushPending.filter(keep);
155
+ return { ...model, pushPending, pushVersions: new Set([...(model.baseline === null ? [] : [model.baseline]), ...pushPending]) };
156
+ }
139
157
  /* ---- the target */
140
158
  /** The database of `--db-url-env` (default DATABASE_URL). */
141
159
  export const resolveTarget = (ctx, options = {}) => options.target ?? dbTarget(ctx, options.urlEnv ?? DEFAULT_DB_URL_ENV);
@@ -176,7 +194,6 @@ export function planMigration(model, remote) {
176
194
  const bootstrap = remote.size === 0 && push.length > 0 && fs.existsSync(path.join(model.root, BOOTSTRAP_FILE));
177
195
  return { done, repairs, reverts, mark, push, bootstrap, missingFiles, unexpected: [...sim].filter((version) => !model.allowed.has(version)), final: sim.size };
178
196
  }
179
- const fileOf = (model, version) => model.files.find((file) => file.startsWith(`${version}_`));
180
197
  function planLines(model, session, remote, plan) {
181
198
  return [
182
199
  `target ${session.target.how}`,
@@ -191,7 +208,7 @@ function planLines(model, session, remote, plan) {
191
208
  }
192
209
  const dataOf = (model, plan, remote, extra) => ({
193
210
  remoteCount: remote.size, repairs: plan.repairs.length, reverts: plan.reverts, baseline: model.baseline, markBaseline: plan.mark, push: plan.push,
194
- heldBack: model.heldVersions, done: plan.done, ...extra,
211
+ heldBack: model.heldVersions, done: plan.done, pushFiles: plan.push.map((version) => fileOf(model, version)), ...extra,
195
212
  });
196
213
  /* ---- applying */
197
214
  /** Applies `versions` in order: each file and its history row in one transaction, so a failure leaves the history true. */
@@ -222,7 +239,7 @@ function applyPlan(session, plan) {
222
239
  * armed with STARCI_DB_ARMED=1 unless the database is on the loopback (the local db service of `npm run db:push`). Already baseline + forward: `nothing to do`, no mutating call, ok.
223
240
  */
224
241
  export async function migrateDatabase(ctx, decl, options = {}) {
225
- const model = buildModel(ctx.cwd, decl);
242
+ const model = phaseModel(buildModel(ctx.cwd, decl), options.phase ?? 'all');
226
243
  const session = { ctx, model, target: resolveTarget(ctx, options) };
227
244
  const remote = listRemote(session);
228
245
  const plan = planMigration(model, remote);
package/db/postgres.d.mts CHANGED
@@ -12,6 +12,8 @@ export interface DbTarget {
12
12
  env: Record<string, string>;
13
13
  secrets: string[];
14
14
  container?: string;
15
+ /** In-container only: the container holds its own credentials (its POSTGRES_PASSWORD_FILE), so no password travels from this machine. */
16
+ own?: boolean;
15
17
  }
16
18
  /** { url, password } of a postgres connection URL with the password removed, or null when `value` is not one. */
17
19
  export declare function parseConnection(value: string): {
@@ -32,6 +34,27 @@ export declare function dbTarget(ctx: VerbContext, name?: string): DbTarget & {
32
34
  export declare function inContainer(target: DbTarget, container: string): DbTarget & {
33
35
  container: string;
34
36
  };
37
+ /** The shell an own-credentials client runs under: the password is read from the container's own secret file (the postgres image's POSTGRES_PASSWORD_FILE), so nothing about it crosses to the machine that runs docker. */
38
+ export declare const OWN_PASSWORD_SHELL = "if [ -n \"${POSTGRES_PASSWORD_FILE:-}\" ] && [ -r \"$POSTGRES_PASSWORD_FILE\" ]; then PGPASSWORD=$(cat \"$POSTGRES_PASSWORD_FILE\"); export PGPASSWORD; fi; exec \"$@\"";
39
+ /** The role and database names a container is allowed to answer with (they reach a connection URL and a remote script). */
40
+ export declare const DB_IDENT: RegExp;
41
+ /**
42
+ * The role and database of the running container `container`: the ones the image was started with (POSTGRES_USER, default postgres;
43
+ * POSTGRES_DB, default the role). Names only, never a secret.
44
+ */
45
+ export declare function containerIdentity(ctx: VerbContext, container: string): {
46
+ role: string;
47
+ database: string;
48
+ };
49
+ /**
50
+ * The database of the running container `container` reached with the credentials that container holds: its own role and database on its own
51
+ * loopback, the password read inside the container from its POSTGRES_PASSWORD_FILE. A CI job that reaches the Docker daemon holds no database
52
+ * URL and no password: the deploy's migrate, emptiness proof and first start go through this target.
53
+ */
54
+ export declare function ownContainerTarget(ctx: VerbContext, container: string): DbTarget & {
55
+ container: string;
56
+ own: true;
57
+ };
35
58
  /** Runs a PostgreSQL client tool against `target`; a failure names the first stderr line with every secret scrubbed (or withholds it). */
36
59
  export declare function pgTool(ctx: VerbContext, target: DbTarget, command: string, args: readonly string[], { withhold, code }?: {
37
60
  withhold?: boolean;
package/db/postgres.mjs CHANGED
@@ -52,7 +52,36 @@ export function inContainer(target, container) {
52
52
  url.hostname = '127.0.0.1';
53
53
  return { ...target, how: `${target.how}, inside container ${container.slice(0, 12)}`, url: url.toString(), container };
54
54
  }
55
- /** Runs `command` of an in-container target: copies each `-f` file in, then execs the tool through the stdin-password shell. */
55
+ /** The shell an own-credentials client runs under: the password is read from the container's own secret file (the postgres image's POSTGRES_PASSWORD_FILE), so nothing about it crosses to the machine that runs docker. */
56
+ export const OWN_PASSWORD_SHELL = 'if [ -n "${POSTGRES_PASSWORD_FILE:-}" ] && [ -r "$POSTGRES_PASSWORD_FILE" ]; then PGPASSWORD=$(cat "$POSTGRES_PASSWORD_FILE"); export PGPASSWORD; fi; exec "$@"';
57
+ /** The role and database names a container is allowed to answer with (they reach a connection URL and a remote script). */
58
+ export const DB_IDENT = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/u;
59
+ /** What the container is asked: its role and database, one per line. */
60
+ const IDENTITY_SCRIPT = 'printf "%s\\n%s\\n" "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}"';
61
+ /**
62
+ * The role and database of the running container `container`: the ones the image was started with (POSTGRES_USER, default postgres;
63
+ * POSTGRES_DB, default the role). Names only, never a secret.
64
+ */
65
+ export function containerIdentity(ctx, container) {
66
+ const short = container.slice(0, 12);
67
+ const run = tool(ctx, 'docker', ['exec', container, 'sh', '-c', IDENTITY_SCRIPT]);
68
+ if (run.error || run.status !== 0)
69
+ throw failure('DB_CLI_FAILED', `cannot read the role and database of container ${short}: ${firstLine(run.stderr)}`);
70
+ const [role, database] = String(run.stdout ?? '').split(/\r?\n/u);
71
+ if (!role || !database || !DB_IDENT.test(role) || !DB_IDENT.test(database))
72
+ throw failure('DB_CLI_FAILED', `container ${short} names a role or database that is not a plain identifier`);
73
+ return { role, database };
74
+ }
75
+ /**
76
+ * The database of the running container `container` reached with the credentials that container holds: its own role and database on its own
77
+ * loopback, the password read inside the container from its POSTGRES_PASSWORD_FILE. A CI job that reaches the Docker daemon holds no database
78
+ * URL and no password: the deploy's migrate, emptiness proof and first start go through this target.
79
+ */
80
+ export function ownContainerTarget(ctx, container) {
81
+ const { role, database } = containerIdentity(ctx, container);
82
+ return { kind: 'url', how: `the database credentials held inside container ${container.slice(0, 12)}`, url: `postgresql://${role}@127.0.0.1:5432/${database}`, env: {}, secrets: [], container, own: true };
83
+ }
84
+ /** Runs `command` of an in-container target: copies each `-f` file in, then execs the tool through the password shell. */
56
85
  function containerRun(ctx, target, command, args) {
57
86
  const short = target.container.slice(0, 12);
58
87
  const step = (what, run) => {
@@ -67,6 +96,8 @@ function containerRun(ctx, target, command, args) {
67
96
  step(`docker cp of ${path.basename(arg)}`, tool(ctx, 'docker', ['cp', arg, `${target.container}:${copied}`]));
68
97
  return copied;
69
98
  });
99
+ if (target.own)
100
+ return tool(ctx, 'docker', ['exec', target.container, 'sh', '-c', OWN_PASSWORD_SHELL, 'sh', command, ...inside]);
70
101
  return tool(ctx, 'docker', ['exec', '-i', target.container, 'sh', '-c', STDIN_PASSWORD, 'sh', command, ...inside], { input: `${target.env.PGPASSWORD ?? ''}\n` });
71
102
  }
72
103
  /** Runs a PostgreSQL client tool against `target`; a failure names the first stderr line with every secret scrubbed (or withholds it). */
package/lint/run.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type CanonDeclarations, type LintSideStats } from '../cache/lint-family.mjs';
2
+ import type { CheckSession } from '../cache/types.mjs';
1
3
  type Dynamic = any;
2
4
  /** The parsed arguments of the lint verb. */
3
5
  export interface LintOptions {
@@ -7,6 +9,11 @@ export interface LintOptions {
7
9
  out: string | undefined;
8
10
  workspace: string | undefined;
9
11
  side?: string;
12
+ full?: boolean;
13
+ }
14
+ /** The cache a lint run may use: the session of the check cache. The caller flushes it after the run. */
15
+ export interface LintCache {
16
+ session: CheckSession;
10
17
  }
11
18
  /** What a linter run answers: its json results, or why it could not complete (both when the run was partial). */
12
19
  export interface LinterRun {
@@ -101,16 +108,55 @@ export declare function eslintRepository({ repoRoot, app, opts, changed, existin
101
108
  }>;
102
109
  };
103
110
  }>;
111
+ /** What the cache reads of the canons a side lints with: the ESLint canon (which publishes the project-graph rules) and the grammar family of the fe side. */
112
+ export declare function canonDeclarations({ repoRoot, cwd, side }: {
113
+ repoRoot: string;
114
+ cwd: string;
115
+ side: string;
116
+ }): Promise<CanonDeclarations[]>;
117
+ /**
118
+ * What the size-growth rules compare an over-budget file with: the merge-base of HEAD with the base branch (STARCI_BASE_REF, origin/main, then
119
+ * main), the same lookup the rules make. The cache keys the files those rules read history for on it, so a base that moved judges them again.
120
+ */
121
+ export declare function gitBaseToken(repoRoot: string): string;
122
+ /** What the cached linters answer: the ESLint answer in the shape of eslintRepository's, the stylelint results, and each side's counts. */
123
+ export interface CachedLinters {
124
+ eslint: Awaited<ReturnType<typeof eslintRepository>>;
125
+ stylelint: {
126
+ results: Dynamic[];
127
+ errors: string[];
128
+ ran: boolean;
129
+ };
130
+ sides: {
131
+ side: string;
132
+ stats: LintSideStats;
133
+ }[];
134
+ }
135
+ /**
136
+ * ESLint and stylelint through the result cache: per side, the files the records cannot vouch for go to the very launcher, chunking and result
137
+ * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
138
+ * results, so the lint report is built the same way from either.
139
+ */
140
+ export declare function cachedLinters({ repoRoot, app, opts, workspace, onFe, run, cache }: {
141
+ repoRoot: string;
142
+ app: RepoDeclaration;
143
+ opts: LintOptions;
144
+ workspace: string | null;
145
+ onFe: string | null;
146
+ run?: LinterRunner | undefined;
147
+ cache: LintCache;
148
+ }): Promise<CachedLinters>;
104
149
  /**
105
150
  * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
106
151
  * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
107
152
  */
108
- export declare function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles, runLinter: linter }: {
153
+ export declare function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles, runLinter: linter, cache }: {
109
154
  repoRoot: string;
110
155
  opts: LintOptions;
111
156
  hfsCheck: (repoRoot: string) => Dynamic;
112
157
  trackedFiles?: (repoRoot: string) => string[];
113
158
  runLinter?: LinterRunner | undefined;
159
+ cache?: LintCache | undefined;
114
160
  }): Promise<{
115
161
  report: Dynamic;
116
162
  sonar: Dynamic;
package/lint/run.mjs CHANGED
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // starci app lint - the ONE lint entry and the ONE report of a StarCi app.
10
10
  //
11
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--json] [--out <file>]
11
+ // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--json] [--out <file>]
12
12
  //
13
13
  // It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
14
14
  // 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
@@ -23,6 +23,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
23
23
  // --workspace scopes the same lint to one npm workspace of the fe side (fe/apps/<app> or fe/packages/<pkg>, app-relative): ESLint and
24
24
  // stylelint over that folder only, and of the app findings only those inside it. It is the `lint` script of every fe workspace (the
25
25
  // turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
26
+ // The result cache (contexts/project/check-cache.md): when the caller hands lintRepository a cache session, ESLint and stylelint judge only what
27
+ // the records of `.starcistacks/cache/lint/<side>/` cannot vouch for (cache/lint-family.mjs) - the same records and fingerprint `app check --lint`
28
+ // uses, so the two verbs never hold two answers. `--side` reads and writes its side's records, `--workspace` reads and writes none, `--changed`
29
+ // and `--fix` bypass the cache, and `--full` judges everything again and rewrites the records.
26
30
  import fs from 'node:fs';
27
31
  import path from 'node:path';
28
32
  import { createRequire } from 'node:module';
@@ -37,6 +41,8 @@ import { byCodeUnit } from '../report/order.mjs';
37
41
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
38
42
  import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
39
43
  import { gateVerdict } from '../runtime/scripts/hfs/gate-threshold.mjs';
44
+ import { mergeBase } from '../runtime/scripts/api/git/merge-base.mjs';
45
+ import { lintFamilyReport, lintSide, loadAppTypeScript } from '../cache/lint-family.mjs';
40
46
  // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
41
47
  export const LINT_SCHEMA = 'starci/lint@1';
42
48
  /** The side whose stylesheets stylelint judges. */
@@ -82,6 +88,8 @@ export function parseLintArgs(argv) {
82
88
  }
83
89
  else if (arg === '--fix')
84
90
  opts.fix = true;
91
+ else if (arg === '--full')
92
+ opts.full = true;
85
93
  else if (arg === '--json')
86
94
  opts.format = 'json';
87
95
  else if (['--out', '--workspace', '--side'].includes(arg)) {
@@ -408,6 +416,140 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
408
416
  });
409
417
  return { findings, errors, raw, engine };
410
418
  }
419
+ // ------------------------------------------------------------------------------------------------ the cached lint
420
+ /** A `crossFileInputs` export that has the shape the cache reads, or null (an installed canon without it, or with a broken one, is linted whole). */
421
+ function declaredInputs(value) {
422
+ if (!Array.isArray(value))
423
+ return null;
424
+ const sound = value.every((entry) => entry && typeof entry.rule === 'string' && (entry.kind === 'content' || entry.kind === 'exists' || entry.kind === 'git') && (entry.relativeTo === 'file' || entry.relativeTo === 'side')
425
+ && Array.isArray(entry.globs) && entry.globs.every((glob) => typeof glob === 'string') && Array.isArray(entry.appliesTo) && entry.appliesTo.every((glob) => typeof glob === 'string'));
426
+ return sound ? value : null;
427
+ }
428
+ const declarationOf = (name, installed, graphRules) => ({
429
+ name, crossFileInputs: declaredInputs(installed?.crossFileInputs), graphRules, projectGraph: graphRules && typeof installed?.projectGraph === 'function' ? installed.projectGraph : null,
430
+ });
431
+ /** What the cache reads of the canons a side lints with: the ESLint canon (which publishes the project-graph rules) and the grammar family of the fe side. */
432
+ export async function canonDeclarations({ repoRoot, cwd, side }) {
433
+ const name = `@starci/eslint-canon-${side}`;
434
+ const canon = await import(__rewriteRelativeImportExtension(pathToFileURL(ownCanonEntry({ repoRoot, cwd }, name)).href));
435
+ const declarations = [declarationOf(name, canon, true)];
436
+ if (side !== 'fe')
437
+ return declarations;
438
+ const family = grammarLintFor(canon.loadHfs(pathToFileURL(path.join(cwd, 'eslint.config.mjs')).href).grammar);
439
+ if (family.package !== null)
440
+ declarations.push(declarationOf(family.package, await import(__rewriteRelativeImportExtension(pathToFileURL(ownCanonEntry({ repoRoot, cwd }, family.package)).href)), false));
441
+ return declarations;
442
+ }
443
+ /** The stylelint canon's declaration; one that cannot be imported has none, so every stylesheet is judged. */
444
+ async function styleDeclaration({ repoRoot, cwd }) {
445
+ const name = '@starci/stylelint-canon';
446
+ try {
447
+ return declarationOf(name, await import(__rewriteRelativeImportExtension(pathToFileURL(ownCanonEntry({ repoRoot, cwd }, name)).href)), false);
448
+ }
449
+ catch {
450
+ return declarationOf(name, null, false);
451
+ }
452
+ }
453
+ /** The stylesheets of the fe side (sync STYLE_GLOB), side-relative, from the disk. */
454
+ function stylesheetsOf(cwd) {
455
+ const patterns = braceVariants(STYLE_GLOB).map(globExpression);
456
+ const found = [];
457
+ const visit = (folder) => {
458
+ let children;
459
+ try {
460
+ children = fs.readdirSync(path.join(cwd, folder), { withFileTypes: true });
461
+ }
462
+ catch {
463
+ return;
464
+ }
465
+ for (const child of children) {
466
+ if (child.name === 'node_modules' || child.name.startsWith('.git'))
467
+ continue;
468
+ const file = posix(path.join(folder, child.name));
469
+ if (child.isDirectory())
470
+ visit(file);
471
+ else if (child.isFile() && patterns.some((pattern) => pattern.test(file)))
472
+ found.push(file);
473
+ }
474
+ };
475
+ for (const container of ['apps', 'packages'])
476
+ visit(container);
477
+ return found.sort(byCodeUnit);
478
+ }
479
+ /**
480
+ * What the size-growth rules compare an over-budget file with: the merge-base of HEAD with the base branch (STARCI_BASE_REF, origin/main, then
481
+ * main), the same lookup the rules make. The cache keys the files those rules read history for on it, so a base that moved judges them again.
482
+ */
483
+ export function gitBaseToken(repoRoot) {
484
+ const named = process.env['STARCI_BASE_REF'];
485
+ for (const ref of named ? [named] : ['origin/main', 'main']) {
486
+ const sha = mergeBase(repoRoot, 'HEAD', ref);
487
+ if (sha)
488
+ return sha;
489
+ }
490
+ return 'no-base';
491
+ }
492
+ /**
493
+ * ESLint and stylelint through the result cache: per side, the files the records cannot vouch for go to the very launcher, chunking and result
494
+ * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
495
+ * results, so the lint report is built the same way from either.
496
+ */
497
+ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run, cache }) {
498
+ const eslint = { findings: [], errors: [], raw: [], engine: { sides: {} } };
499
+ const style = { results: [], errors: [], ran: false };
500
+ const sides = [];
501
+ const decision = cache.session.decide('lint');
502
+ const full = opts.full === true ? '--full' : decision.mode === 'full' ? decision.reason : null;
503
+ const gitToken = gitBaseToken(repoRoot);
504
+ for (const side of SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate)) {
505
+ if (workspace !== null && side !== STYLE_SIDE) {
506
+ eslint.engine.sides[side] = { files: 0, skipped: `outside --workspace ${workspace}` };
507
+ continue;
508
+ }
509
+ const cwd = path.join(repoRoot, side);
510
+ let scope, canons;
511
+ try {
512
+ scope = await sourceFiles({ repoRoot, cwd, side, scope: null });
513
+ canons = await canonDeclarations({ repoRoot, cwd, side });
514
+ }
515
+ catch (error) {
516
+ eslint.errors.push(`eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}`);
517
+ eslint.engine.sides[side] = { files: 0 };
518
+ continue;
519
+ }
520
+ const styled = side === STYLE_SIDE;
521
+ const outcome = await lintSide({
522
+ session: cache.session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
523
+ report: onFe === null ? null : (file) => file.startsWith(`${onFe}/`),
524
+ canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: workspace === null,
525
+ eslint: async (files) => {
526
+ const listed = files === null ? null : files.map((file) => `${side}/${file}`);
527
+ const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], workspace: listed === null ? workspace : null, onFe: listed === null ? onFe : null, run });
528
+ return { results: sub.raw, errors: sub.errors };
529
+ },
530
+ stylelint: styled ? async (files) => {
531
+ const linted = await runLinter({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json'] });
532
+ const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
533
+ for (const result of linted.results ?? [])
534
+ for (const warning of result.warnings ?? [])
535
+ warning.text = appRelative(warning.text);
536
+ return { results: (linted.results ?? []), errors: linted.error ? [linted.error] : [] };
537
+ } : null,
538
+ });
539
+ sides.push({ side, stats: outcome.stats });
540
+ for (const message of outcome.errors)
541
+ (message.startsWith('stylelint ') ? style.errors : eslint.errors).push(message);
542
+ eslint.raw.push(...outcome.eslint);
543
+ eslint.findings.push(...eslintFindings(outcome.eslint, repoRoot));
544
+ eslint.engine.sides[side] = { files: outcome.eslint.length };
545
+ if (styled) {
546
+ style.results.push(...outcome.stylelint);
547
+ style.ran = true;
548
+ }
549
+ }
550
+ cache.session.report(lintFamilyReport(sides));
551
+ return { eslint, stylelint: style, sides };
552
+ }
411
553
  /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
412
554
  function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
413
555
  if (opts.side === 'be')
@@ -421,7 +563,7 @@ function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
421
563
  * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
422
564
  * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
423
565
  */
424
- export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [], runLinter: linter }) {
566
+ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [], runLinter: linter, cache }) {
425
567
  const changed = opts.changed === null ? null : new Set(opts.changed);
426
568
  const errors = [];
427
569
  let workspace = null;
@@ -467,8 +609,11 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
467
609
  /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
468
610
  const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
469
611
  // The linters and the app check are independent: every child starts first, the app check runs while they work, and the results are read in a fixed order.
470
- const eslintRun = app ? eslintRepository({ app, repoRoot, opts, changed: changed ? [...changed] : null, existing, workspace, onFe, run: linter }) : null;
471
- const styleLaunch = app ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
612
+ // The cache serves a whole-side lint only: --changed judges named files and --fix rewrites them, so neither reads nor writes a record.
613
+ const cached = cache !== undefined && changed === null && !opts.fix ? cache : null;
614
+ const cachedRun = app && cached ? cachedLinters({ app, repoRoot, opts, workspace, onFe, run: linter, cache: cached }) : null;
615
+ const eslintRun = app ? (cachedRun ? cachedRun.then((answer) => answer.eslint) : eslintRepository({ app, repoRoot, opts, changed: changed ? [...changed] : null, existing, workspace, onFe, run: linter })) : null;
616
+ const styleLaunch = app && !cachedRun ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
472
617
  const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value, error: undefined }), (error) => ({ value: undefined, error })) : null;
473
618
  // 1. ESLint, once per side, from the side folder with that side's config.
474
619
  const eslint = eslintRun ? await eslintRun : { engine: { sides: {} }, errors: [], raw: [], findings: [] };
@@ -478,7 +623,14 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
478
623
  findings.push(...eslint.findings);
479
624
  // 3. stylelint over the fe side's stylesheets.
480
625
  if (app) {
481
- if (styleLaunch) {
626
+ if (cachedRun) {
627
+ const answer = (await cachedRun).stylelint;
628
+ errors.push(...answer.errors);
629
+ if (answer.ran)
630
+ raw.stylelint = answer.results;
631
+ findings.push(...stylelintFindings(answer.results, repoRoot));
632
+ }
633
+ else if (styleLaunch) {
482
634
  const linted = await styleLaunch;
483
635
  if (linted.error)
484
636
  errors.push(linted.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.12.2",
3
+ "version": "4.13.2",
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",
@@ -63,6 +63,7 @@
63
63
  "release/**",
64
64
  "db/**",
65
65
  "ci/**",
66
+ "cache/**",
66
67
  "coverage/**",
67
68
  "codemod/**",
68
69
  "lint/**",
@@ -64,6 +64,8 @@ export declare const RELEASE_CODE = "RELEASE_CONFIG_INVALID";
64
64
  /** The note a release that does not wait for CI carries in its L4 record and its push output (owner ruling release-require-ci-per-project). */
65
65
  export declare const CI_NOT_REQUIRED = "CI not required by release.requireCi=false (owner ruling release-require-ci-per-project): the local release gate, this L4 record and the annotated tag, is the release proof";
66
66
  export declare const VERSION: RegExp;
67
+ /** A release judges in full and never reads the committed check cache (contexts/project/check-cache.md): every gate row, the urgent back-fill included, runs with this set. */
68
+ export declare const RELEASE_CACHE_ENV: Readonly<Record<string, string>>;
67
69
  /** True for a branch or remote name git can take as one argument (no leading dash, only path-like characters). */
68
70
  export declare const refName: (value: unknown) => value is string;
69
71
  /** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
package/release/gate.mjs CHANGED
@@ -32,6 +32,8 @@ const THRESHOLD_MISS = /coverage threshold .* not met/gu;
32
32
  const WRAPPED = new Set(['npm', 'npx']);
33
33
  // Every row the managed CI runs, so a release that does not wait for its candidate CI (release.requireCi false) still ran them all locally:
34
34
  // fast (audit, format, typecheck), lint, test + coverage (the managed `test` script is the per-file 100 threshold), build, the lite database lint, and Sonar.
35
+ /** A release judges in full and never reads the committed check cache (contexts/project/check-cache.md): every gate row, the urgent back-fill included, runs with this set. */
36
+ export const RELEASE_CACHE_ENV = Object.freeze({ STARCI_APP_CHECK_CACHE: 'full' });
35
37
  const SCRIPT_GATES = ['format:check', 'typecheck', 'build:be', 'lint', 'db:lint', 'build:fe', 'test'];
36
38
  // A full app has a test world (integration, e2e and contract projects, always present: the managed `test:e2e` script marks it), so those layers are rows of its gate;
37
39
  // a lite app's integration project exists only where it has integration specs, so it has no world rows.
@@ -270,7 +272,7 @@ export async function runAvailableGate(ctx, gate, options) {
270
272
  return runGate(ctx, gate, options);
271
273
  // The scanner imports the app's declared external issues report: produce it first with the app's own lint, as the managed CI does.
272
274
  const started = ctx.now();
273
- const lint = produceLintReports(ctx, { launch, platform: options.platform, env: withoutCredentials(ctx.env, options.credentialNames) });
275
+ const lint = produceLintReports(ctx, { launch, platform: options.platform, env: { ...withoutCredentials(ctx.env, options.credentialNames), ...RELEASE_CACHE_ENV } });
274
276
  if (lint.pass)
275
277
  return judgeQualityGate(ctx, gate, runGate(ctx, gate, { ...options, preface: lint.text }));
276
278
  fs.writeFileSync(log, lint.text);
@@ -303,7 +305,7 @@ export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '
303
305
  const started = ctx.now();
304
306
  const [command, args] = launch(gate.command, platform);
305
307
  // The native scanner alone consumes Sonar credentials; ordinary gates cannot inherit or reintroduce them.
306
- const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)) };
308
+ const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)), ...RELEASE_CACHE_ENV };
307
309
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
308
310
  const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
309
311
  const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;