@starci/hfs 5.3.0 → 5.5.0

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 (137) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/account-doors.mjs +7 -1
  6. package/codemod/app-prettier.d.mts +5 -0
  7. package/codemod/app-prettier.mjs +27 -0
  8. package/codemod/explicit-surface.mjs +1 -25
  9. package/codemod/main.mjs +14 -5
  10. package/codemod/module-definition.d.mts +10 -3
  11. package/codemod/module-definition.mjs +32 -17
  12. package/codemod/seeds-layout.d.mts +36 -0
  13. package/codemod/seeds-layout.mjs +113 -0
  14. package/codemod/sops-rules.d.mts +1 -1
  15. package/codemod/sops-rules.mjs +8 -3
  16. package/lint/run.d.mts +5 -4
  17. package/lint/run.mjs +53 -27
  18. package/package.json +1 -1
  19. package/release/lint-report.mjs +4 -3
  20. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  21. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  22. package/runtime/knowledge/hfs/rules.yaml +15 -2
  23. package/runtime/knowledge/hfs/slots.yaml +15 -18
  24. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  25. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  26. package/runtime/scripts/hfs/check.mjs +4 -1
  27. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  28. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  29. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  30. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  31. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  32. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  33. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  34. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  35. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  37. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  38. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  39. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  43. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  44. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  45. package/runtime/scripts/hfs/slots.d.mts +2 -0
  46. package/runtime/scripts/hfs/slots.mjs +1 -0
  47. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  48. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  49. package/runtime/scripts/hfs/view.d.mts +25 -0
  50. package/runtime/scripts/hfs/view.mjs +41 -0
  51. package/scaffold/add-app.mjs +2 -2
  52. package/scaffold/add-cli-lite.mjs +41 -14
  53. package/scaffold/add-module.mjs +6 -3
  54. package/scaffold/lite-exports.mjs +1 -1
  55. package/scaffold/module-registry.mjs +10 -8
  56. package/src/main.mjs +8 -8
  57. package/stack/deploy.d.mts +3 -0
  58. package/stack/deploy.mjs +4 -1
  59. package/stack/local-up.mjs +25 -3
  60. package/stack/seeds.d.mts +29 -0
  61. package/stack/seeds.mjs +197 -0
  62. package/sync/canon-layout.d.mts +38 -0
  63. package/sync/canon-layout.mjs +62 -0
  64. package/sync/ci-commands.d.mts +2 -1
  65. package/sync/ci-commands.mjs +10 -7
  66. package/sync/compile-guard.d.mts +36 -0
  67. package/sync/compile-guard.mjs +189 -0
  68. package/sync/index.d.mts +22 -3
  69. package/sync/index.mjs +80 -32
  70. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  71. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  72. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  73. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  74. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  76. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  77. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  78. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  79. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  80. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  81. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  82. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  83. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  84. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  85. package/templates/app/skeleton/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  87. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  92. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  93. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  94. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  95. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  96. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  97. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  98. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  99. package/templates/app/skeleton-lite/README.md +2 -2
  100. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
  101. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  108. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  110. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  113. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  116. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  117. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  118. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
  119. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
  120. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  121. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  122. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  123. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  124. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  125. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  126. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  127. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  128. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  129. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  130. package/templates/be/tool-config/jest.config.js +1 -1
  131. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  132. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  133. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  134. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  135. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  136. package/templates/app/module/jest.config.js.tpl +0 -8
  137. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -31,7 +31,7 @@ function codecovSteps(lcovReport, codecov) {
31
31
  ].join('\n');
32
32
  }
33
33
  /** Sonar is required: on every full run it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
34
- function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
34
+ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout, scopes) {
35
35
  if (!sonar)
36
36
  return '';
37
37
  const condition = SONAR_CONDITION;
@@ -64,7 +64,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
64
64
  ' pattern: sonar-lint-*',
65
65
  ' path: reports',
66
66
  ' merge-multiple: true',
67
- ...sonarMergeSteps(condition),
67
+ ...sonarMergeSteps(condition, scopes),
68
68
  ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
69
69
  ` if: ${condition}`,
70
70
  ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
@@ -137,15 +137,15 @@ function frontendBuild(source, app) {
137
137
  ].filter(Boolean).join('\n')).join('\n');
138
138
  return { env: singleEnv, steps };
139
139
  }
140
- /** The per-side lint reports (one artifact per lint matrix side) join into the one reports/lint.sonar.json the Sonar import reads, even when one side wrote none. The local release gate writes the same file with mergeSonarReports (release/lint-report.mjs): keep both identical. */
141
- function sonarMergeSteps(condition) {
140
+ /** The per-scope lint reports (one artifact per lint matrix scope) join into the one reports/lint.sonar.json the Sonar import reads, even when one scope wrote none. The local release gate writes the same file with mergeSonarReports (release/lint-report.mjs): keep both identical. */
141
+ function sonarMergeSteps(condition, scopes) {
142
142
  return [
143
143
  ' - name: merge Sonar lint reports',
144
144
  ` if: ${condition}`,
145
145
  ' run: |',
146
146
  " node --input-type=module <<'NODE'",
147
147
  " import fs from 'node:fs';",
148
- " const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
148
+ ` const files = [${scopes.map((scope) => `'reports/lint.${scope}.sonar.json'`).join(', ')}];`,
149
149
  " const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
150
150
  ' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
151
151
  ' const issues = reports.flatMap(report => report.issues);',
@@ -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, packages = false } = {}) {
182
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app, packages = false, rootPackages = 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' };
@@ -188,8 +188,11 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
188
188
  const build = frontendBuild(source, app);
189
189
  const settings = ciJobSettings(buildDeclaration(source), source);
190
190
  const side = '${{ matrix.side }}';
191
+ // The lint matrix: one job per side, and one for the root packages of an app that has them (their scope runs whatever became of the sides').
192
+ const scopes = ['be', 'fe', ...(rootPackages ? ['packages'] : [])];
191
193
  return {
192
194
  ...settings,
195
+ lintSides: `[${scopes.join(', ')}]`,
193
196
  lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
194
197
  // The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
195
198
  shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
@@ -224,7 +227,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
224
227
  codecovSteps: codecovSteps(lcovReport, codecov),
225
228
  verdictSonarNeed: sonar ? ', sonar' : '',
226
229
  verdictSonarCheck: sonar ? `\n if [[ "$FULL" == true && "$CHECKS" == true ]]; then expect sonar success; else expect sonar idle; fi` : '',
227
- sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
230
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar, scopes),
228
231
  lcovArtifact,
229
232
  };
230
233
  }
@@ -0,0 +1,36 @@
1
+ /** The TypeScript compiler API of the app's own install, typed by the version this package develops against. */
2
+ type Ts = typeof import('typescript');
3
+ /** The refusal code of a held group. */
4
+ export declare const GROUP_HELD = "HFS_SYNC_GROUP_HELD";
5
+ /** The pseudo-group of the retired files a sync deletes. */
6
+ export declare const RETIRED_GROUP = "retired";
7
+ /** One write the guard judges: an app-relative path, its managed group, and its new text (null: the file is deleted). */
8
+ export interface GuardedWrite {
9
+ path: string;
10
+ group: string;
11
+ text: string | null;
12
+ }
13
+ /** A group sync does not write, with its files and the errors its writes would add. */
14
+ export interface HeldGroup {
15
+ group: string;
16
+ files: string[];
17
+ errors: string[];
18
+ }
19
+ /** What the guard answers: the held groups, and the sides it could not compile. */
20
+ export interface GuardVerdict {
21
+ held: HeldGroup[];
22
+ unchecked: string[];
23
+ }
24
+ /** Whether `file` (app-relative) is TypeScript source a side compiles. */
25
+ export declare const isGuardedSource: (file: string) => boolean;
26
+ /** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
27
+ export declare function projectOf(root: string, file: string): string | null;
28
+ /**
29
+ * The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
30
+ * TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
31
+ * named in `unchecked`.
32
+ */
33
+ export declare function guardWrites(root: string, writes: readonly GuardedWrite[], loadTs?: (dir: string) => Ts | null): GuardVerdict;
34
+ /** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
35
+ export declare function heldLines(group: HeldGroup): string[];
36
+ export {};
@@ -0,0 +1,189 @@
1
+ // compile-guard.mjs - HFS_SYNC_GROUP_HELD: `starci app sync --execute` never writes a render that leaves the app uncompilable. Before it writes
2
+ // a managed TypeScript source (or deletes a retired one), the side's own TypeScript compiles the app with the writes laid over the disk and
3
+ // compares the diagnostics of every file they touch, and of every file that imports one, with the same files on disk. A managed group whose
4
+ // writes add an error (a template that reads a name the app does not provide yet, or drops one the app still imports) is held whole: none
5
+ // of its files is written, the rest of the render is, and the sync names the group, its files and the errors, so the app provides what the
6
+ // render reads and runs sync again. A side whose TypeScript is not installed cannot be compiled; its writes go through and say so.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
10
+ /** The refusal code of a held group. */
11
+ export const GROUP_HELD = 'HFS_SYNC_GROUP_HELD';
12
+ /** The pseudo-group of the retired files a sync deletes. */
13
+ export const RETIRED_GROUP = 'retired';
14
+ /** How many errors a held group prints. */
15
+ const SHOWN_ERRORS = 20;
16
+ const SOURCE = /\.tsx?$/;
17
+ /** Whether `file` (app-relative) is TypeScript source a side compiles. */
18
+ export const isGuardedSource = (file) => SOURCE.test(file) && !file.endsWith('.d.ts') && (file.startsWith('be/') || file.startsWith('fe/'));
19
+ /** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
20
+ export function projectOf(root, file) {
21
+ const parts = file.split('/');
22
+ const project = parts[0] === 'be' ? 'be/tsconfig.json' : parts.length > 3 ? `fe/${parts[1]}/${parts[2]}/tsconfig.json` : null;
23
+ return project !== null && fs.existsSync(path.join(root, project)) ? project : null;
24
+ }
25
+ /** One side's compilation: its compiler, options and root files, the overlay of writes keyed the way the host compares file names. */
26
+ class Side {
27
+ #ts;
28
+ #root;
29
+ #options;
30
+ #rootNames;
31
+ #baseline = new Map();
32
+ #program;
33
+ constructor(ts, root, project) {
34
+ this.#ts = ts;
35
+ this.#root = root;
36
+ const file = path.join(root, project);
37
+ const read = ts.readConfigFile(file, ts.sys.readFile);
38
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(file), { noEmit: true }, file);
39
+ this.#options = { ...parsed.options, noEmit: true };
40
+ this.#rootNames = parsed.fileNames;
41
+ }
42
+ /** The name the host compares: absolute, forward slashes, lower case where the file system ignores case. */
43
+ key(file) {
44
+ const absolute = path.resolve(this.#root, file).replaceAll('\\', '/');
45
+ return this.#ts.sys.useCaseSensitiveFileNames ? absolute : absolute.toLowerCase();
46
+ }
47
+ #host(overlay) {
48
+ const host = this.#ts.createCompilerHost(this.#options, true);
49
+ const { readFile, fileExists, getSourceFile } = host;
50
+ host.readFile = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) ?? undefined : readFile.call(host, name));
51
+ host.fileExists = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) !== null : fileExists.call(host, name));
52
+ host.getSourceFile = (name, language, onError, create) => {
53
+ if (!overlay.has(this.key(name)))
54
+ return getSourceFile.call(host, name, language, onError, create);
55
+ const text = overlay.get(this.key(name));
56
+ return text === null || text === undefined ? undefined : this.#ts.createSourceFile(name, text, language, true);
57
+ };
58
+ return host;
59
+ }
60
+ #programOf(overlay, added) {
61
+ const rootNames = [...this.#rootNames.filter((name) => overlay.get(this.key(name)) !== null), ...added];
62
+ return this.#ts.createProgram({ rootNames, options: this.#options, host: this.#host(overlay), ...(this.#program === undefined ? {} : { oldProgram: this.#program }) });
63
+ }
64
+ /** The files of the program that import one of `targets` (keys), each with the targets it imports. */
65
+ importers(targets, overlay) {
66
+ const host = this.#host(overlay);
67
+ const cache = this.#ts.createModuleResolutionCache(this.#root, (name) => this.key(name), this.#options);
68
+ const found = new Map();
69
+ for (const name of this.#rootNames) {
70
+ const text = host.readFile(name);
71
+ if (text === undefined)
72
+ continue;
73
+ for (const imported of this.#ts.preProcessFile(text, true, true).importedFiles) {
74
+ const resolved = this.#ts.resolveModuleName(imported.fileName, name, this.#options, host, cache).resolvedModule?.resolvedFileName;
75
+ if (resolved === undefined || !targets.has(this.key(resolved)))
76
+ continue;
77
+ const into = found.get(this.key(name)) ?? new Set();
78
+ into.add(this.key(resolved));
79
+ found.set(this.key(name), into);
80
+ }
81
+ }
82
+ return found;
83
+ }
84
+ /** The errors of `files` (keys) as `path TScode message`, each file once, in the program with `overlay` laid over the disk. */
85
+ errors(files, overlay, added) {
86
+ const program = this.#programOf(overlay, added);
87
+ if (overlay.size === 0)
88
+ this.#program = program;
89
+ const byKey = new Map(program.getSourceFiles().map((source) => [this.key(source.fileName), source]));
90
+ const answer = new Map();
91
+ for (const file of files) {
92
+ const source = byKey.get(file);
93
+ const relative = path.relative(this.#root, source?.fileName ?? file).replaceAll('\\', '/');
94
+ const found = source === undefined ? [] : [...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source)];
95
+ answer.set(file, found.map((diagnostic) => `${relative}${source === undefined || diagnostic.start === undefined ? '' : `:${String(source.getLineAndCharacterOfPosition(diagnostic.start).line + 1)}`} TS${String(diagnostic.code)} ${this.#ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`));
96
+ }
97
+ return answer;
98
+ }
99
+ /** The errors of `files` on disk, computed once per file. */
100
+ baseline(files) {
101
+ const missing = files.filter((file) => !this.#baseline.has(file));
102
+ if (missing.length > 0)
103
+ for (const [file, found] of this.errors(missing, new Map(), []))
104
+ this.#baseline.set(file, found);
105
+ return new Map(files.map((file) => [file, this.#baseline.get(file) ?? []]));
106
+ }
107
+ }
108
+ /** An error without its line, so a line moved by the write is the same error. */
109
+ const unplaced = (error) => error.replace(/^([^ ]*?):\d+ /, '$1 ');
110
+ /** The errors of `after` that `before` does not hold, counted as a multiset of unplaced errors. */
111
+ function added(before, after) {
112
+ const left = new Map();
113
+ for (const error of before)
114
+ left.set(unplaced(error), (left.get(unplaced(error)) ?? 0) + 1);
115
+ return after.filter((error) => {
116
+ const count = left.get(unplaced(error)) ?? 0;
117
+ left.set(unplaced(error), count - 1);
118
+ return count <= 0;
119
+ });
120
+ }
121
+ /** The held groups of one side: compile with every write of the groups still in play, hold the groups that add an error, again until none does. */
122
+ function judgeSide(side, writes) {
123
+ const held = [];
124
+ let open = [...writes];
125
+ while (open.length > 0) {
126
+ const overlay = new Map(open.map((write) => [side.key(write.path), write.text]));
127
+ const groupOf = new Map(open.map((write) => [side.key(write.path), write.group]));
128
+ // An importer is found on the disk with the new files added, so the importer of a file the writes delete is found too.
129
+ const importers = side.importers(new Set(overlay.keys()), new Map([...overlay].filter(([, text]) => text !== null)));
130
+ const files = [...new Set([...[...overlay.keys()].filter((file) => overlay.get(file) !== null), ...importers.keys()])];
131
+ const before = side.baseline(files);
132
+ const after = side.errors(files, overlay, open.filter((write) => write.text !== null).map((write) => path.resolve(write.path)));
133
+ const errors = new Map();
134
+ for (const file of files) {
135
+ const fresh = added(before.get(file) ?? [], after.get(file) ?? []);
136
+ if (fresh.length === 0)
137
+ continue;
138
+ const groups = groupOf.has(file) ? [groupOf.get(file)] : [...(importers.get(file) ?? [])].map((target) => groupOf.get(target));
139
+ for (const group of groups)
140
+ errors.set(group, [...(errors.get(group) ?? []), ...fresh]);
141
+ }
142
+ if (errors.size === 0)
143
+ break;
144
+ for (const [group, found] of errors)
145
+ held.push({ group, files: open.filter((write) => write.group === group).map((write) => write.path), errors: [...new Set(found)] });
146
+ open = open.filter((write) => !errors.has(write.group));
147
+ }
148
+ return held;
149
+ }
150
+ /**
151
+ * The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
152
+ * TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
153
+ * named in `unchecked`.
154
+ */
155
+ export function guardWrites(root, writes, loadTs = (dir) => loadTypescript(dir, root)) {
156
+ const verdict = { held: [], unchecked: [] };
157
+ const bySide = new Map();
158
+ for (const write of writes.filter((candidate) => isGuardedSource(candidate.path))) {
159
+ const project = projectOf(root, write.path);
160
+ if (project !== null)
161
+ bySide.set(project, [...(bySide.get(project) ?? []), { ...write, path: path.join(root, write.path) }]);
162
+ }
163
+ for (const [project, sideWrites] of bySide) {
164
+ const ts = loadTs(path.join(root, path.dirname(project)));
165
+ if (ts === null) {
166
+ verdict.unchecked.push(project);
167
+ continue;
168
+ }
169
+ for (const group of judgeSide(new Side(ts, root, project), sideWrites)) {
170
+ const relative = group.files.map((file) => path.relative(root, file).replaceAll('\\', '/'));
171
+ const known = verdict.held.find((entry) => entry.group === group.group);
172
+ if (known === undefined)
173
+ verdict.held.push({ ...group, files: relative });
174
+ else
175
+ verdict.held.splice(verdict.held.indexOf(known), 1, { group: group.group, files: [...known.files, ...relative], errors: [...known.errors, ...group.errors] });
176
+ }
177
+ }
178
+ return verdict;
179
+ }
180
+ /** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
181
+ export function heldLines(group) {
182
+ const more = group.errors.length > SHOWN_ERRORS ? [` ... ${String(group.errors.length - SHOWN_ERRORS)} more`] : [];
183
+ return [
184
+ `${GROUP_HELD} ${group.group}: ${String(group.files.length)} files not written, the render adds ${String(group.errors.length)} type errors to the app (${group.files.join(', ')})`,
185
+ ...group.errors.slice(0, SHOWN_ERRORS).map((error) => ` ${error}`),
186
+ ...more,
187
+ ` make the app provide what the render reads (each error names it), then run "npx --no-install starci app sync --execute" again`,
188
+ ];
189
+ }
package/sync/index.d.mts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type PackagesCoverage } from '../runtime/scripts/hfs/coverage-scope.mjs';
1
2
  import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
2
3
  import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
3
4
  import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
5
+ import { guardWrites, type GuardedWrite } from './compile-guard.mjs';
4
6
  import type { ImageApp } from './stack-images.mjs';
5
7
  export declare const TEMPLATES_DIR: string;
6
8
  /** The scopes a target is rendered for: the app root, then each side. */
@@ -22,6 +24,7 @@ export interface Target {
22
24
  template: string;
23
25
  mode: string;
24
26
  slot?: string;
27
+ group?: string;
25
28
  markdown?: boolean;
26
29
  }
27
30
  /** The markers around a managed block. */
@@ -39,6 +42,8 @@ export interface RenderedTarget {
39
42
  scripts?: Record<string, unknown>;
40
43
  markdown?: boolean | undefined;
41
44
  markers?: Markers;
45
+ /** The managed group of a slot's file (`managedBy`), which sync writes or holds whole (compile-guard.mjs). */
46
+ group?: string;
42
47
  }
43
48
  /** What comparing a rendered target with the disk answers. */
44
49
  export interface TargetCheck {
@@ -64,6 +69,7 @@ export interface AppSourceFacts {
64
69
  read: (file: string) => string;
65
70
  workPresent: boolean;
66
71
  hasImage: (file: string) => boolean;
72
+ hasContracts: boolean;
67
73
  modules?: readonly string[];
68
74
  otherPackages?: readonly string[];
69
75
  stack: any;
@@ -142,6 +148,8 @@ export declare function sonarScope(presets: Presets | null | undefined): {
142
148
  };
143
149
  /** The managed ESLint config at the app root that lints the root workspace packages. */
144
150
  export declare const PACKAGES_LINT_FILE = "eslint.config.mjs";
151
+ /** The app-relative folders of the root packages whose logic the back end's unit run measures: every module package and every other package, never the registry. */
152
+ export declare const rootPackageFolders: (source: Partial<Pick<AppSourceFacts, "modules" | "otherPackages">> | undefined) => string[];
145
153
  export declare function appSource(root: string, { parseYaml, notices }?: {
146
154
  parseYaml?: ParseYaml | undefined;
147
155
  notices?: string[] | undefined;
@@ -184,12 +192,19 @@ export declare function imageApps(app: ResolvedApp, source: Partial<Pick<AppSour
184
192
  /**
185
193
  * The Dockerfile of every declared app, [{ path, content }] (app-relative): written once by starci app scaffold from templates/<side>/image/<kind>
186
194
  * and then the app's own, judged by the docker rules (R187-R191). A Next image copies the fe packages only when the fe side opts into
187
- * them and the be contracts only when the fe side reads them, so each COPY names a folder the app has.
195
+ * them and the be contracts only when the fe side reads them and the back end holds them (`source.hasContracts`: a back end that emits no
196
+ * contract has no be/contracts/, and a COPY of a missing folder fails the image build), so each COPY names a folder the app has.
188
197
  */
189
- export declare function imageFiles(app: ResolvedApp, source?: Partial<Pick<AppSourceFacts, 'hasImage'>>): {
198
+ export declare function imageFiles(app: ResolvedApp, source?: Partial<Pick<AppSourceFacts, 'hasImage' | 'hasContracts'>>): {
190
199
  path: string;
191
200
  content: string;
192
201
  }[];
202
+ /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
203
+ /**
204
+ * The `packages` option of the managed be/jest.config.js (`starciJestConfig`), laid out as prettier lays it out (width 120, indent 4): the root packages whose
205
+ * specs the unit run holds, with the files measured in them; an app without one has no such option.
206
+ */
207
+ export declare function jestPackagesSource(packages: PackagesCoverage): string;
193
208
  export declare function variables(app: ResolvedApp, scope: string, presets: Presets | null | undefined, sonarKey: string | null | undefined, manifest?: SlotManifest, source?: Pick<AppSourceFacts, 'files' | 'read'> & Partial<AppSourceFacts>): Record<string, string>;
194
209
  /**
195
210
  * The rendered targets of an app declaration. Without source facts the app counts the back-end source files its own targets
@@ -229,9 +244,13 @@ export declare function renderRepo(root: string, { presets, parseYaml, notices,
229
244
  export declare function retiredFiles(root: string, hfs: {
230
245
  edition?: string;
231
246
  } | null | undefined): string[];
232
- export declare function runSync(argv: string[], { cwd, out, presets, parseYaml }?: {
247
+ /** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
248
+ export declare function guardedWrites(root: string, targets: readonly RenderedTarget[], retired: readonly string[]): GuardedWrite[];
249
+ export declare function runSync(argv: string[], { cwd, out, presets, parseYaml, loadTs }?: {
233
250
  cwd?: string;
234
251
  out?: (line: string) => unknown;
235
252
  presets?: Presets | undefined;
236
253
  parseYaml?: ParseYaml | undefined;
254
+ /** The TypeScript of a side folder the compile guard compiles with; the app's own install by default. */
255
+ loadTs?: Parameters<typeof guardWrites>[2];
237
256
  }): Promise<number>;