@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.
- package/CHANGELOG.md +50 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +20 -8
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +33 -0
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +7 -1
- package/stack/cli.mjs +22 -13
- package/stack/config.d.mts +15 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +22 -5
- package/stack/deploy.mjs +242 -72
- package/stack/journal.d.mts +146 -0
- package/stack/journal.mjs +191 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +49 -1
- package/stack/release-store.mjs +102 -16
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +32 -11
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +51 -0
- package/stack/status.mjs +115 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- 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
|
-
|
|
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 ||
|
|
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
|
-
/**
|
|
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
|
-
|
|
471
|
-
const
|
|
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 (
|
|
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.
|
|
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/**",
|
package/release/gate.d.mts
CHANGED
|
@@ -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;
|