@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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/account-doors.mjs +7 -1
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +14 -5
- package/codemod/module-definition.d.mts +10 -3
- package/codemod/module-definition.mjs +32 -17
- package/codemod/seeds-layout.d.mts +36 -0
- package/codemod/seeds-layout.mjs +113 -0
- package/codemod/sops-rules.d.mts +1 -1
- package/codemod/sops-rules.mjs +8 -3
- package/lint/run.d.mts +5 -4
- package/lint/run.mjs +53 -27
- package/package.json +1 -1
- package/release/lint-report.mjs +4 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +15 -2
- package/runtime/knowledge/hfs/slots.yaml +15 -18
- package/runtime/modules/kernel/failure-codes.yaml +64 -3
- package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
- package/runtime/scripts/hfs/db-squash.mjs +9 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
- package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
- package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
- package/runtime/scripts/hfs/rules/database.d.mts +2 -2
- package/runtime/scripts/hfs/rules/database.mjs +7 -6
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
- package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
- package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
- package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
- package/runtime/scripts/hfs/slots.d.mts +2 -0
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
- package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
- package/runtime/scripts/hfs/view.d.mts +25 -0
- package/runtime/scripts/hfs/view.mjs +41 -0
- package/scaffold/add-app.mjs +2 -2
- package/scaffold/add-cli-lite.mjs +41 -14
- package/scaffold/add-module.mjs +6 -3
- package/scaffold/lite-exports.mjs +1 -1
- package/scaffold/module-registry.mjs +10 -8
- package/src/main.mjs +8 -8
- package/stack/deploy.d.mts +3 -0
- package/stack/deploy.mjs +4 -1
- package/stack/local-up.mjs +25 -3
- package/stack/seeds.d.mts +29 -0
- package/stack/seeds.mjs +197 -0
- package/sync/canon-layout.d.mts +38 -0
- package/sync/canon-layout.mjs +62 -0
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +10 -7
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/index.d.mts +22 -3
- package/sync/index.mjs +80 -32
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
- package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
- package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
- package/templates/be/tool-config/jest.config.js +1 -1
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
- package/templates/app/module/jest.config.js.tpl +0 -8
- /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
package/sync/ci-commands.mjs
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>;
|