@starci/hfs 4.12.2 → 4.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -0,0 +1,235 @@
1
+ // quality-baselines.mts - the `--sonar` family of the app check cache (contexts/project/check-cache.md, "Sonar and Codecov"). `sonar-baseline/` and `codecov-baseline/` hold
2
+ // REFERENCE data only: the base commit of the new code, the quality gate's conditions as read from their owner with that owner's digest, the analysis scope's digest, and the
3
+ // patch and project targets of codecov.yml with its digest. They never hold a server's result. From them and the merged coverage a run predicts what the servers will say,
4
+ // and every prediction is labelled as one: Sonar's new-code coverage and duplication on the lines changed since the base commit, Codecov's patch coverage and project
5
+ // change (the base total computed from the base commit's committed coverage records). The servers stay the judges: a prediction never passes or fails a run.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { hashText } from './hash.mjs';
8
+ import { parseRecords } from './jsonl.mjs';
9
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
10
+ export const SONAR_FOLDER = 'sonar-baseline';
11
+ export const CODECOV_FOLDER = 'codecov-baseline';
12
+ /** The step of every reference record. */
13
+ export const REFERENCE_STEP = 'reference';
14
+ /** The label every prediction carries and every printed line states. */
15
+ export const PREDICTION = 'prediction';
16
+ /** The unit-spec records of the test family, read by folder and step (kept in step with test-family.mts, which owns them). */
17
+ const TEST_FOLDER = 'test-coverage';
18
+ const SPEC_STEP = 'unit-spec';
19
+ function numberAt(document, keys, what) {
20
+ let value = document;
21
+ for (const key of keys)
22
+ value = typeof value === 'object' && value !== null ? value[key] : undefined;
23
+ const parsed = typeof value === 'string' ? Number.parseFloat(value) : value;
24
+ if (typeof parsed !== 'number' || !Number.isFinite(parsed))
25
+ throw new Error(`${what} is not declared at ${keys.join('.')}`);
26
+ return parsed;
27
+ }
28
+ /** The gate conditions the predictions are compared with, read from the gate document (`newCode.coverage.minPercent`, `newCode.duplication.maxPercent`). */
29
+ export function readSonarGate(gateYaml) {
30
+ const document = parseYaml(gateYaml);
31
+ return { coverageMinPercent: numberAt(document, ['newCode', 'coverage', 'minPercent'], 'the Sonar gate'), duplicationMaxPercent: numberAt(document, ['newCode', 'duplication', 'maxPercent'], 'the Sonar gate') };
32
+ }
33
+ /** The patch and project targets of codecov.yml (`coverage.status.{project,patch}.default.target`, `90%`). */
34
+ export function readCodecovTargets(codecovYaml) {
35
+ const document = parseYaml(codecovYaml);
36
+ return {
37
+ project: numberAt(document, ['coverage', 'status', 'project', 'default', 'target'], 'codecov.yml'),
38
+ patch: numberAt(document, ['coverage', 'status', 'patch', 'default', 'target'], 'codecov.yml'),
39
+ };
40
+ }
41
+ const SCOPE_KEY = /^sonar\.(?:sources|tests|inclusions|exclusions|test\.inclusions|test\.exclusions|coverage\.exclusions|cpd\.exclusions|javascript\.lcov\.reportPaths|typescript\.tsconfigPaths)$/u;
42
+ /** The digest of the analysis scope settings of sonar-project.properties: the settings that decide which lines Sonar sees, sorted, nothing else. */
43
+ export function scopeDigest(propertiesText) {
44
+ const settings = propertiesText.split(/\r?\n/u).flatMap((line) => {
45
+ const match = /^\s*([A-Za-z0-9_.-]+)\s*[=:]\s*(.*?)\s*$/u.exec(line);
46
+ return match !== null && SCOPE_KEY.test(match[1] ?? '') ? [`${match[1]}=${match[2]}`] : [];
47
+ });
48
+ return hashText(settings.sort().join('\n'));
49
+ }
50
+ const reference = (key, hash, data) => ({ path: key, step: REFERENCE_STEP, hash, findings: [], data });
51
+ /** The records of the two baseline folders: reference data and digests, never a result of a server. */
52
+ export function baselineRecords(sources) {
53
+ const gate = readSonarGate(sources.sonarGateYaml);
54
+ const targets = readCodecovTargets(sources.codecovYaml);
55
+ return {
56
+ sonar: [
57
+ reference('new-code-base', sources.baseCommit, { baseCommit: sources.baseCommit }),
58
+ reference('quality-gate', hashText(sources.sonarGateYaml), { coverageMinPercent: gate.coverageMinPercent, duplicationMaxPercent: gate.duplicationMaxPercent, owner: 'runtime/knowledge/sonar-gate.yaml' }),
59
+ reference('analysis-scope', scopeDigest(sources.sonarProperties), { digestOf: 'sonar-project.properties' }),
60
+ ],
61
+ codecov: [
62
+ reference('base', sources.baseCommit, { baseCommit: sources.baseCommit }),
63
+ reference('targets', hashText(sources.codecovYaml), { project: targets.project, patch: targets.patch, owner: 'codecov.yml' }),
64
+ ],
65
+ };
66
+ }
67
+ /** The reference parts already stored whose digest differs from the current one (a first adoption has none): the gate, the scope or the targets moved since the baseline was written. */
68
+ export function baselineDrift(stored, current) {
69
+ const before = new Map(stored.map((record) => [record.path, record.hash]));
70
+ return current.filter((record) => before.has(record.path) && before.get(record.path) !== record.hash).map((record) => record.path).sort();
71
+ }
72
+ /** The lines a `git diff -U0 <base>` adds or changes, per file (`+++ b/path`, hunk headers `@@ -a,b +c,d @@`); a deleted file has none. */
73
+ export function changedLinesFromDiff(diff) {
74
+ const out = {};
75
+ let file;
76
+ for (const line of diff.split(/\r?\n/u)) {
77
+ if (line.startsWith('+++ ')) {
78
+ const target = line.slice(4).trim();
79
+ file = target === '/dev/null' ? undefined : target.replace(/^b\//u, '');
80
+ if (file !== undefined)
81
+ out[file] = out[file] ?? [];
82
+ continue;
83
+ }
84
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/u.exec(line);
85
+ if (hunk !== null && file !== undefined) {
86
+ const start = Number(hunk[1]);
87
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
88
+ for (let i = 0; i < count; i += 1)
89
+ out[file]?.push(start + i);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * The line coverage the unit-spec records stand for: a line is instrumented when a statement starts on it and covered when any spec covers one that does. Equal for a
96
+ * base commit's committed records and the work tree's, so the project change is computed on the same footing.
97
+ */
98
+ export function lineCoverageOf(records) {
99
+ const instrumented = new Map();
100
+ const covered = new Map();
101
+ for (const record of records) {
102
+ if (record.step !== SPEC_STEP)
103
+ continue;
104
+ const data = record.data;
105
+ const sources = typeof data === 'object' && data !== null && !Array.isArray(data) ? data['sources'] : undefined;
106
+ if (typeof sources !== 'object' || sources === null || Array.isArray(sources))
107
+ continue;
108
+ for (const [source, items] of Object.entries(sources)) {
109
+ const statements = typeof items === 'object' && items !== null && !Array.isArray(items) ? items['s'] : undefined;
110
+ if (!Array.isArray(statements))
111
+ continue;
112
+ const all = instrumented.get(source) ?? new Set();
113
+ const hit = covered.get(source) ?? new Set();
114
+ for (const statement of statements) {
115
+ if (!Array.isArray(statement) || typeof statement[0] !== 'number')
116
+ continue;
117
+ all.add(statement[0]);
118
+ if (statement[4] === 1)
119
+ hit.add(statement[0]);
120
+ }
121
+ instrumented.set(source, all);
122
+ covered.set(source, hit);
123
+ }
124
+ }
125
+ return Object.fromEntries([...instrumented].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([source, lines]) => [source, { instrumented: [...lines].sort((a, b) => a - b), covered: [...(covered.get(source) ?? [])].sort((a, b) => a - b) }]));
126
+ }
127
+ /** The base commit's coverage records, read from the commit itself (`git show <base>:<path>`), so the base total is computed and not stored. */
128
+ export function recordsAtCommit(baseCommit, git, folder = `.starcistacks/cache/${TEST_FOLDER}`) {
129
+ const listing = git(['ls-tree', '-r', '--name-only', baseCommit, '--', folder]);
130
+ if (listing === undefined)
131
+ return [];
132
+ return listing.split(/\r?\n/u).filter((name) => name.endsWith('.jsonl')).flatMap((name) => parseRecords(git(['show', `${baseCommit}:${name}`]) ?? '').records);
133
+ }
134
+ /** `git` run in `cwd`, undefined when it fails (an unreadable base commit is no base total, not a crash). */
135
+ export function gitRunner(cwd) {
136
+ return (args) => {
137
+ const result = spawnSync('git', [...args], { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
138
+ return result.status === 0 ? result.stdout : undefined;
139
+ };
140
+ }
141
+ const percent = (part, whole) => (whole === 0 ? undefined : Math.round((part / whole) * 10000) / 100);
142
+ /** Covered and coverable lines among the changed lines of the measured sources. */
143
+ function changedCoverage(inputs) {
144
+ let covered = 0;
145
+ let coverable = 0;
146
+ for (const [file, lines] of Object.entries(inputs.changed)) {
147
+ const coverage = inputs.coverage[file];
148
+ if (coverage === undefined || inputs.measured?.(file) === false)
149
+ continue;
150
+ const instrumented = new Set(coverage.instrumented);
151
+ const hit = new Set(coverage.covered);
152
+ for (const line of new Set(lines)) {
153
+ if (!instrumented.has(line))
154
+ continue;
155
+ coverable += 1;
156
+ if (hit.has(line))
157
+ covered += 1;
158
+ }
159
+ }
160
+ return { covered, coverable };
161
+ }
162
+ function total(coverage, measured) {
163
+ let covered = 0;
164
+ let coverable = 0;
165
+ for (const [file, lines] of Object.entries(coverage)) {
166
+ if (measured?.(file) === false)
167
+ continue;
168
+ coverable += lines.instrumented.length;
169
+ covered += lines.covered.length;
170
+ }
171
+ return { covered, coverable };
172
+ }
173
+ /** Sonar's new-code coverage and duplication and Codecov's patch coverage and project change, each labelled as a prediction. */
174
+ export function predict(inputs) {
175
+ const patch = changedCoverage(inputs);
176
+ const out = [
177
+ { label: PREDICTION, provider: 'sonar', metric: 'new-code coverage', value: percent(patch.covered, patch.coverable), basis: `${patch.covered} of ${patch.coverable} coverable changed lines`, target: `>= ${inputs.gate.coverageMinPercent}%` },
178
+ ];
179
+ let newLines = 0;
180
+ let duplicated = 0;
181
+ for (const [file, lines] of Object.entries(inputs.changed)) {
182
+ if (inputs.analysed?.(file) === false)
183
+ continue;
184
+ const changed = new Set(lines);
185
+ newLines += changed.size;
186
+ const inClone = new Set();
187
+ for (const block of inputs.clones)
188
+ if (block.path === file)
189
+ for (let line = block.startLine; line <= block.endLine; line += 1)
190
+ if (changed.has(line))
191
+ inClone.add(line);
192
+ duplicated += inClone.size;
193
+ }
194
+ out.push({ label: PREDICTION, provider: 'sonar', metric: 'new-code duplication', value: percent(duplicated, newLines), basis: `${duplicated} of ${newLines} changed lines`, target: `<= ${inputs.gate.duplicationMaxPercent}%` });
195
+ out.push({ label: PREDICTION, provider: 'codecov', metric: 'patch coverage', value: percent(patch.covered, patch.coverable), basis: `${patch.covered} of ${patch.coverable} changed lines with coverage`, target: `>= ${inputs.targets.patch}%` });
196
+ const now = total(inputs.coverage, inputs.measured);
197
+ const nowPercent = percent(now.covered, now.coverable);
198
+ if (inputs.baseCoverage === undefined) {
199
+ out.push({ label: PREDICTION, provider: 'codecov', metric: 'project change', value: undefined, basis: 'the base commit holds no readable coverage records', target: `project >= ${inputs.targets.project}%` });
200
+ }
201
+ else {
202
+ const base = total(inputs.baseCoverage, inputs.measured);
203
+ const basePercent = percent(base.covered, base.coverable);
204
+ const change = nowPercent === undefined || basePercent === undefined ? undefined : Math.round((nowPercent - basePercent) * 100) / 100;
205
+ out.push({ label: PREDICTION, provider: 'codecov', metric: 'project change', value: change, basis: `${nowPercent ?? 'n/a'}% now against ${basePercent ?? 'n/a'}% at the base commit`, target: `project >= ${inputs.targets.project}%` });
206
+ }
207
+ return out;
208
+ }
209
+ /** The lines a run prints: each states that it is a local prediction and what the server holds the metric to. */
210
+ export function formatPredictions(predictions) {
211
+ return predictions.map((entry) => `${entry.provider}: ${entry.metric} ${entry.value === undefined ? 'not measurable' : `${entry.value}%`} (${entry.basis}) [${entry.label} from the local run, not the server's result; the server holds it to ${entry.target}]`);
212
+ }
213
+ /**
214
+ * The `--sonar` family: writes the two baseline folders from the owners' files and predicts from the merged coverage. It always runs (the predictions read the changed
215
+ * lines, which no key holds) and it never fails a run: `ok` does not exist here, the servers judge.
216
+ */
217
+ export function runQualityFamily({ session, sources, changed, clones, baseCoverage, measured, analysed }) {
218
+ const records = baselineRecords(sources);
219
+ const drift = [...new Set([...baselineDrift(session.records(SONAR_FOLDER), records.sonar), ...baselineDrift(session.records(CODECOV_FOLDER), records.codecov)])].sort();
220
+ session.replace(SONAR_FOLDER, records.sonar);
221
+ session.replace(CODECOV_FOLDER, records.codecov);
222
+ const predictions = predict({
223
+ changed,
224
+ coverage: lineCoverageOf(session.records(TEST_FOLDER)),
225
+ baseCoverage,
226
+ clones,
227
+ gate: readSonarGate(sources.sonarGateYaml),
228
+ targets: readCodecovTargets(sources.codecovYaml),
229
+ ...(measured === undefined ? {} : { measured }),
230
+ ...(analysed === undefined ? {} : { analysed }),
231
+ });
232
+ const report = { family: 'sonar', mode: 'full', reason: 'predictions always run', checked: predictions.length, reused: 0, newFindings: 0, knownFindings: 0, fixedFindings: 0 };
233
+ session.report(report);
234
+ return { predictions, lines: formatPredictions(predictions), drift, report };
235
+ }
@@ -0,0 +1,33 @@
1
+ import type { CacheRecord, CheckSession, ContentIndex, Finding, JsonValue } from './types.mjs';
2
+ export declare class RecordSet {
3
+ private readonly records;
4
+ constructor(initial?: readonly CacheRecord[]);
5
+ /** The record of (path, step) whose hash is `hash`, else undefined: a record of other content is no answer. */
6
+ matching(path: string, step: string, hash: string): CacheRecord | undefined;
7
+ put(record: CacheRecord): void;
8
+ /** Drops every record of `step` whose path is not in `keep`: a deleted path drops every record it owns. */
9
+ retain(step: string, keep: ReadonlySet<string>): void;
10
+ /** Every finding the records of `step` hold. */
11
+ findings(step: string): Finding[];
12
+ toArray(): CacheRecord[];
13
+ }
14
+ /** The key of an input set: the sha256 of its members and their hashes, `absent` for a member the work tree no longer holds. */
15
+ export declare function keyOfPaths(index: ContentIndex, paths: readonly string[], extra?: Readonly<Record<string, string>>): string;
16
+ /**
17
+ * One scoped step: reuses the recorded `data` when the family runs diff and the record's key is `key`; else computes, and stores unless the
18
+ * step's inputs (`inputs`, tracked paths) were edited during the run or `store(value)` says the result must not be kept (a timeout is never
19
+ * stored). `compact` is the record's findings, for the new / known / fixed comparison.
20
+ */
21
+ export declare function scopedStep<T extends JsonValue>(session: CheckSession, records: RecordSet, step: {
22
+ readonly path: string;
23
+ readonly step: string;
24
+ readonly key: string;
25
+ readonly reuse: boolean;
26
+ readonly inputs: readonly string[];
27
+ compute(): T | Promise<T>;
28
+ compact(value: T): Finding[];
29
+ store?(value: T): boolean;
30
+ }): Promise<{
31
+ value: T;
32
+ reused: boolean;
33
+ }>;
@@ -0,0 +1,54 @@
1
+ // records.mts - the working set of one folder during a run: the records the family inherited, replaced key by key as its steps judge, then
2
+ // handed back to the session in one `replace`. A step that throws puts nothing, so it keeps (or loses) no record of that step.
3
+ import { compare } from './order.mjs';
4
+ import { combineHashes } from './hash.mjs';
5
+ const keyOf = (path, step) => `${path}\0${step}`;
6
+ export class RecordSet {
7
+ records = new Map();
8
+ constructor(initial = []) {
9
+ for (const record of initial)
10
+ this.records.set(keyOf(record.path, record.step), record);
11
+ }
12
+ /** The record of (path, step) whose hash is `hash`, else undefined: a record of other content is no answer. */
13
+ matching(path, step, hash) {
14
+ const record = this.records.get(keyOf(path, step));
15
+ return record?.hash === hash ? record : undefined;
16
+ }
17
+ put(record) {
18
+ this.records.set(keyOf(record.path, record.step), record);
19
+ }
20
+ /** Drops every record of `step` whose path is not in `keep`: a deleted path drops every record it owns. */
21
+ retain(step, keep) {
22
+ for (const [key, record] of this.records)
23
+ if (record.step === step && !keep.has(record.path))
24
+ this.records.delete(key);
25
+ }
26
+ /** Every finding the records of `step` hold. */
27
+ findings(step) {
28
+ return [...this.records.values()].filter((record) => record.step === step).flatMap((record) => record.findings);
29
+ }
30
+ toArray() {
31
+ return [...this.records.values()].sort((a, b) => compare(a.path, b.path) || compare(a.step, b.step));
32
+ }
33
+ }
34
+ /** The key of an input set: the sha256 of its members and their hashes, `absent` for a member the work tree no longer holds. */
35
+ export function keyOfPaths(index, paths, extra = {}) {
36
+ const parts = { ...extra };
37
+ for (const file of paths)
38
+ parts[`path:${file}`] = index.hashOf(file) ?? 'absent';
39
+ return combineHashes(parts);
40
+ }
41
+ /**
42
+ * One scoped step: reuses the recorded `data` when the family runs diff and the record's key is `key`; else computes, and stores unless the
43
+ * step's inputs (`inputs`, tracked paths) were edited during the run or `store(value)` says the result must not be kept (a timeout is never
44
+ * stored). `compact` is the record's findings, for the new / known / fixed comparison.
45
+ */
46
+ export async function scopedStep(session, records, step) {
47
+ const held = step.reuse ? records.matching(step.path, step.step, step.key) : undefined;
48
+ if (held?.data !== undefined)
49
+ return { value: held.data, reused: true };
50
+ const value = await step.compute();
51
+ if ((step.store?.(value) ?? true) && session.unchanged(step.inputs))
52
+ records.put({ path: step.path, step: step.step, hash: step.key, findings: step.compact(value), data: value });
53
+ return { value, reused: false };
54
+ }
@@ -0,0 +1,19 @@
1
+ import { type Finding, type FamilyReport, type FamilyId, type RunMode } from './types.mjs';
2
+ /** `hfs: diff (against the cache at 1a2b3c4d5e6f), checked 12, reused 3400; findings 1 new, 4 known, 0 fixed` */
3
+ export declare function familyLine(report: FamilyReport): string;
4
+ /** The findings this run produced against the ones the records held: new, known (held already) and fixed (held, no longer produced). */
5
+ export declare function compareFindings(previous: readonly Finding[], current: readonly Finding[]): {
6
+ newFindings: number;
7
+ knownFindings: number;
8
+ fixedFindings: number;
9
+ };
10
+ /** A family report from a decision, the work it did and the findings before and after. */
11
+ export declare function makeReport(input: {
12
+ family: FamilyId;
13
+ mode: RunMode;
14
+ reason: string;
15
+ checked: number;
16
+ reused: number;
17
+ previous: readonly Finding[];
18
+ current: readonly Finding[];
19
+ }): FamilyReport;
@@ -0,0 +1,32 @@
1
+ // report.mts - what a family says about itself: one diagnosis line, and the comparison of its findings with the ones its records held.
2
+ import {} from './types.mjs';
3
+ /** `hfs: diff (against the cache at 1a2b3c4d5e6f), checked 12, reused 3400; findings 1 new, 4 known, 0 fixed` */
4
+ export function familyLine(report) {
5
+ return `${report.family}: ${report.mode} (${report.reason}), checked ${report.checked}, reused ${report.reused}; findings ${report.newFindings} new, ${report.knownFindings} known, ${report.fixedFindings} fixed`;
6
+ }
7
+ const keyOf = (finding) => `${finding.path}\0${finding.rule}\0${finding.message}`;
8
+ /** The findings this run produced against the ones the records held: new, known (held already) and fixed (held, no longer produced). */
9
+ export function compareFindings(previous, current) {
10
+ const before = new Map();
11
+ for (const finding of previous)
12
+ before.set(keyOf(finding), (before.get(keyOf(finding)) ?? 0) + 1);
13
+ let known = 0;
14
+ let fresh = 0;
15
+ for (const finding of current) {
16
+ const held = before.get(keyOf(finding)) ?? 0;
17
+ if (held > 0) {
18
+ known += 1;
19
+ before.set(keyOf(finding), held - 1);
20
+ }
21
+ else
22
+ fresh += 1;
23
+ }
24
+ let fixed = 0;
25
+ for (const left of before.values())
26
+ fixed += left;
27
+ return { newFindings: fresh, knownFindings: known, fixedFindings: fixed };
28
+ }
29
+ /** A family report from a decision, the work it did and the findings before and after. */
30
+ export function makeReport(input) {
31
+ return { family: input.family, mode: input.mode, reason: input.reason, checked: input.checked, reused: input.reused, ...compareFindings(input.previous, input.current) };
32
+ }
@@ -0,0 +1,17 @@
1
+ import type { FamilyReport } from './types.mjs';
2
+ export declare const RUN_LOG_LIMIT = 100;
3
+ /** One step or family of a run: how long it took. */
4
+ export interface TimedFamily extends FamilyReport {
5
+ readonly seconds: number;
6
+ }
7
+ /** One run. */
8
+ export interface RunEntry {
9
+ readonly command: string;
10
+ readonly seconds: number;
11
+ readonly exit: number;
12
+ readonly families: readonly TimedFamily[];
13
+ }
14
+ /** The folder of the untracked tool cache for the app at `root`. */
15
+ export declare function toolCacheDir(root: string, env?: NodeJS.ProcessEnv): string;
16
+ /** Writes `last-run.json` and appends to `runs.jsonl`, keeping the last 100 runs. A disk error is no failure of the run. */
17
+ export declare function writeRunLog(root: string, entry: RunEntry, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,37 @@
1
+ // run-log.mts - the timings of a run. They change every run, so they are host state and stay out of git: `last-run.json` and `runs.jsonl`
2
+ // (the last 100 runs) in the untracked tool cache of slot app.tool-cache,
3
+ // `${STARCI_CACHE_HOME:-%LOCALAPPDATA%/StarCi/cache}/<repo>/app-check/`, where <repo> is the app folder's name and the first 12 hex digits of
4
+ // the sha256 of its real path.
5
+ import { createHash } from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ export const RUN_LOG_LIMIT = 100;
10
+ /** The folder of the untracked tool cache for the app at `root`. */
11
+ export function toolCacheDir(root, env = process.env) {
12
+ const home = env['STARCI_CACHE_HOME'] ?? (process.platform === 'win32' ? path.join(env['LOCALAPPDATA'] ?? path.join(os.homedir(), 'AppData', 'Local'), 'StarCi', 'cache') : path.join(env['XDG_CACHE_HOME'] ?? path.join(os.homedir(), '.cache'), 'starci'));
13
+ let real = path.resolve(root);
14
+ try {
15
+ real = fs.realpathSync(real);
16
+ }
17
+ catch { /* the given path names the app */ }
18
+ const id = `${path.basename(real)}-${createHash('sha256').update(real).digest('hex').slice(0, 12)}`;
19
+ return path.join(home, id, 'app-check');
20
+ }
21
+ /** Writes `last-run.json` and appends to `runs.jsonl`, keeping the last 100 runs. A disk error is no failure of the run. */
22
+ export function writeRunLog(root, entry, env = process.env) {
23
+ try {
24
+ const dir = toolCacheDir(root, env);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ fs.writeFileSync(path.join(dir, 'last-run.json'), `${JSON.stringify(entry, null, 2)}\n`);
27
+ const file = path.join(dir, 'runs.jsonl');
28
+ let lines = [];
29
+ try {
30
+ lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
31
+ }
32
+ catch { /* the first run */ }
33
+ lines.push(JSON.stringify(entry));
34
+ fs.writeFileSync(file, `${lines.slice(-RUN_LOG_LIMIT).join('\n')}\n`);
35
+ }
36
+ catch { /* host state only */ }
37
+ }
@@ -0,0 +1,33 @@
1
+ import { type CheckFinding, type RepositoryResult } from '../runtime/scripts/hfs/check.mjs';
2
+ import type { Presets } from '../sync/index.mjs';
3
+ import type { Prettier } from '../sync/format.mjs';
4
+ import { type CheckSession, type FamilyId, type FamilyReport } from './types.mjs';
5
+ /** The families of a run with no selector, in the order they run. Families a work package adds register in `extraFamilies`. */
6
+ export declare const DEFAULT_FAMILIES: readonly FamilyId[];
7
+ export interface AppRunInput {
8
+ readonly repoRoot: string;
9
+ readonly tracked: readonly string[];
10
+ readonly session: CheckSession;
11
+ readonly selected: ReadonlySet<FamilyId>;
12
+ readonly harness?: {
13
+ version?: string;
14
+ } | undefined;
15
+ readonly presets?: Presets | undefined;
16
+ readonly prettier?: Prettier | undefined;
17
+ readonly edition?: string | undefined;
18
+ /** Limits the formatter to these app-relative files (`app lint --changed`). */
19
+ readonly only?: readonly string[] | undefined;
20
+ /** The ref the baseline database rules compare with, when the caller names one. */
21
+ readonly base?: string | undefined;
22
+ }
23
+ export interface AppRunResult extends RepositoryResult {
24
+ readonly families: readonly FamilyReport[];
25
+ /** The wiring proof, when the wiring family ran. */
26
+ wiring?: {
27
+ status: string;
28
+ apps: string[];
29
+ findings: CheckFinding[];
30
+ };
31
+ }
32
+ /** Runs the selected families against `session` and merges their findings. The caller flushes the session. */
33
+ export declare function runAppFamilies(input: AppRunInput): Promise<AppRunResult>;
package/cache/run.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // run.mts - one `starci app check` / `starci app lint` pass over the selected families, against one session. It owns the order of the families,
2
+ // the judging of their findings (edition and the Vietnamese why of every code) and the merge into the one result the verb prints and the gate
3
+ // judges, so the two verbs never hold two answers for one tree.
4
+ import { skillRoot } from '../runtime/engine/runtime-root.mjs';
5
+ import { readWhy } from '../runtime/scripts/hfs/check.mjs';
6
+ import { judgedFindings, summarize, withWhy } from '../runtime/scripts/hfs/check-results.mjs';
7
+ import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
8
+ import { runDbFamily } from './db-family.mjs';
9
+ import { runFormatFamily } from './format-family.mjs';
10
+ import { runStructurePass } from './hfs-family.mjs';
11
+ import { runWiringFamily } from './wiring-family.mjs';
12
+ import { FAMILIES } from './types.mjs';
13
+ /** The families of a run with no selector, in the order they run. Families a work package adds register in `extraFamilies`. */
14
+ export const DEFAULT_FAMILIES = FAMILIES.filter((family) => ['hfs', 'format', 'db', 'secrets', 'wiring', 'types', 'lint', 'test', 'sonar'].includes(family));
15
+ const emptyResult = (repoRoot, tracked) => ({
16
+ ok: true, repoRoot, manifest: '', profile: null, apps: [], tracked, findings: [], counts: summarize([]),
17
+ machine: { status: 'skipped', reason: 'the structure family was not selected' },
18
+ });
19
+ /** Runs the selected families against `session` and merges their findings. The caller flushes the session. */
20
+ export async function runAppFamilies(input) {
21
+ const { repoRoot, session, selected } = input;
22
+ const reports = [];
23
+ const track = (report) => void (report && reports.push(report));
24
+ const structure = selected.has('hfs') || selected.has('secrets');
25
+ const pass = structure
26
+ ? await runStructurePass(session, { repoRoot, harness: input.harness, presets: input.presets, tracked: input.tracked, hfs: selected.has('hfs'), secrets: selected.has('secrets') })
27
+ : null;
28
+ if (pass) {
29
+ track(pass.report);
30
+ track(pass.secretsReport);
31
+ }
32
+ const base = pass?.result ?? emptyResult(repoRoot, input.tracked.length);
33
+ const extra = [];
34
+ if (selected.has('format')) {
35
+ const formatted = await runFormatFamily(session, { files: input.only ?? input.tracked, prettier: input.prettier });
36
+ track(formatted.report);
37
+ extra.push(...formatted.findings);
38
+ }
39
+ if (selected.has('db')) {
40
+ const database = await runDbFamily(session, { repoRoot, tracked: input.tracked, base: input.base });
41
+ track(database.report);
42
+ extra.push(...database.findings);
43
+ }
44
+ let wiring;
45
+ if (selected.has('wiring')) {
46
+ const proved = await runWiringFamily(session, { repoRoot, edition: input.edition, files: input.tracked });
47
+ track(proved.report);
48
+ wiring = { status: proved.wiring.status, apps: proved.wiring.apps, findings: proved.wiring.findings };
49
+ extra.push(...proved.wiring.findings);
50
+ }
51
+ const edition = input.edition ?? declaredEdition(repoRoot);
52
+ const judged = extra.length === 0 ? [] : withWhy(judgedFindings(extra, edition, skillRoot), readWhy(skillRoot, [...new Set(extra.map((finding) => finding.code))]));
53
+ // A family's findings reach the result once: the pass already holds the ones the always-run rules produced.
54
+ const findings = [...base.findings, ...judged];
55
+ const counts = summarize(findings);
56
+ return { ...base, findings, counts, ok: counts.error === 0, families: reports, ...(wiring ? { wiring } : {}) };
57
+ }
58
+ function declaredEdition(repoRoot) {
59
+ try {
60
+ return readRepoDeclaration(loadSlotManifest(), repoRoot).edition ?? 'full';
61
+ }
62
+ catch {
63
+ return 'full';
64
+ }
65
+ }
@@ -0,0 +1,29 @@
1
+ import { type Fingerprint } from './fingerprint.mjs';
2
+ import { type CacheVerdict, type TrustSource } from './trust.mjs';
3
+ import { type CheckSession, type FamilyId, type FamilyReport, type ProgressSink } from './types.mjs';
4
+ /** `STARCI_APP_CHECK_CACHE`: `full` ignores the cache (the release gate sets it), `verify` is read by the caller (runs diff, then full, compares). */
5
+ export declare const CACHE_ENV = "STARCI_APP_CHECK_CACHE";
6
+ export interface DiskSessionOptions {
7
+ readonly root: string;
8
+ readonly tracked: readonly string[];
9
+ readonly env: NodeJS.ProcessEnv;
10
+ readonly progress: ProgressSink;
11
+ readonly cliVersion?: string | undefined;
12
+ /** The families `--full` selects. */
13
+ readonly full?: ReadonlySet<FamilyId> | undefined;
14
+ /** Seams for specs. */
15
+ readonly trust?: TrustSource | undefined;
16
+ readonly fingerprint?: Fingerprint | undefined;
17
+ readonly hfsDir?: string | undefined;
18
+ }
19
+ /** What the disk session adds to the contract: the fingerprint and trust it works under, and the CI verdicts. */
20
+ export interface DiskSession extends CheckSession {
21
+ readonly fingerprint: Fingerprint;
22
+ readonly trust: TrustSource;
23
+ readonly reports: readonly FamilyReport[];
24
+ /** Whether this run may write the cache folder (a host run with a portable fingerprint that is not a forced-full release run). */
25
+ readonly writes: boolean;
26
+ /** In CI after `flush`: the committed cache compared with what the run inherited or computed. */
27
+ verdicts(): readonly CacheVerdict[];
28
+ }
29
+ export declare function createDiskSession(options: DiskSessionOptions): DiskSession;