@starci/hfs 4.8.0 → 4.9.1

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 (144) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/emit/operation-schemas.mjs +3 -0
  3. package/lint/bound-sys.cjs +2 -2
  4. package/package.json +1 -1
  5. package/runtime/engine/admission.mjs +1 -1
  6. package/runtime/engine/config.mjs +2 -2
  7. package/runtime/engine/db/machine.mjs +2 -3
  8. package/runtime/engine/invalid-config.mjs +1 -1
  9. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  10. package/runtime/knowledge/hfs/rules.yaml +23 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  12. package/runtime/scripts/guards/git-policy.mjs +2 -3
  13. package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
  14. package/runtime/scripts/lib/package-at.mjs +5 -6
  15. package/runtime/scripts/lib/roots.mjs +2 -2
  16. package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
  17. package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
  18. package/scaffold/app.mjs +4 -4
  19. package/src/main.mjs +3 -3
  20. package/sync/index.mjs +14 -1
  21. package/sync/managed.mjs +3 -2
  22. package/templates/app/codegen-graphql.mjs.partial +4 -4
  23. package/templates/app/quality-config/sonar-project.properties +4 -2
  24. package/runtime/engine/canonical-json.mjs +0 -14
  25. package/runtime/engine/db/ledger-open.mjs +0 -38
  26. package/runtime/engine/db/ledger-paths.mjs +0 -87
  27. package/runtime/engine/db/ledger.mjs +0 -1068
  28. package/runtime/engine/db/provision-authority.mjs +0 -76
  29. package/runtime/engine/db/provision-operations.mjs +0 -146
  30. package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
  31. package/runtime/scripts/agent/codex-app-server.mjs +0 -79
  32. package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
  33. package/runtime/scripts/agent/host-agents.mjs +0 -80
  34. package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
  35. package/runtime/scripts/agent/trust-launch.mjs +0 -191
  36. package/runtime/scripts/agent/trust.mjs +0 -523
  37. package/runtime/scripts/api/fs/temp-path.mjs +0 -8
  38. package/runtime/scripts/api/git/diff-names.mjs +0 -8
  39. package/runtime/scripts/api/git/head-time.mjs +0 -10
  40. package/runtime/scripts/api/git/index-lock.mjs +0 -78
  41. package/runtime/scripts/api/git/log.mjs +0 -5
  42. package/runtime/scripts/api/git/merge-tree.mjs +0 -5
  43. package/runtime/scripts/api/git/rev-list.mjs +0 -5
  44. package/runtime/scripts/api/git/rev-parse.mjs +0 -13
  45. package/runtime/scripts/api/git/status-query.mjs +0 -5
  46. package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
  47. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  48. package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
  49. package/runtime/scripts/api/npm/lib.mjs +0 -32
  50. package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
  51. package/runtime/scripts/api/npm/run-npm.mjs +0 -6
  52. package/runtime/scripts/api/npm/view.mjs +0 -30
  53. package/runtime/scripts/api/npm/whoami.mjs +0 -8
  54. package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
  55. package/runtime/scripts/api/process/kill-tree.mjs +0 -22
  56. package/runtime/scripts/api/process/lib.mjs +0 -125
  57. package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
  58. package/runtime/scripts/api/process/process-list.mjs +0 -28
  59. package/runtime/scripts/api/process/system-tool.mjs +0 -37
  60. package/runtime/scripts/gates/canon-digest.mjs +0 -81
  61. package/runtime/scripts/gates/canon-pins.mjs +0 -119
  62. package/runtime/scripts/gates/gate-input.mjs +0 -98
  63. package/runtime/scripts/gates/gate.mjs +0 -636
  64. package/runtime/scripts/gates/grammar-dist.mjs +0 -206
  65. package/runtime/scripts/gates/output.mjs +0 -40
  66. package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
  67. package/runtime/scripts/gates/test-world-run.mjs +0 -252
  68. package/runtime/scripts/gates/type-impact.mjs +0 -277
  69. package/runtime/scripts/gates/type-program.mjs +0 -113
  70. package/runtime/scripts/guards/assigned-command.mjs +0 -12
  71. package/runtime/scripts/guards/call-rights.mjs +0 -47
  72. package/runtime/scripts/guards/command-guard.mjs +0 -468
  73. package/runtime/scripts/guards/command-policy.mjs +0 -342
  74. package/runtime/scripts/guards/critic-reach.mjs +0 -82
  75. package/runtime/scripts/guards/deps-guard.mjs +0 -150
  76. package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
  77. package/runtime/scripts/guards/guards-root.mjs +0 -27
  78. package/runtime/scripts/guards/hook-install.mjs +0 -343
  79. package/runtime/scripts/guards/hook-verdict.mjs +0 -12
  80. package/runtime/scripts/guards/install-verdict.mjs +0 -41
  81. package/runtime/scripts/guards/kernel-seat.mjs +0 -63
  82. package/runtime/scripts/guards/launch-verdict.mjs +0 -74
  83. package/runtime/scripts/guards/op-starci.mjs +0 -16
  84. package/runtime/scripts/guards/op-write-scope.mjs +0 -27
  85. package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
  86. package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
  87. package/runtime/scripts/guards/protected-zone.mjs +0 -56
  88. package/runtime/scripts/guards/refusals.mjs +0 -27
  89. package/runtime/scripts/guards/rights.mjs +0 -268
  90. package/runtime/scripts/guards/seat-tools.mjs +0 -47
  91. package/runtime/scripts/guards/shell-commands.mjs +0 -328
  92. package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
  93. package/runtime/scripts/guards/verdict-process.mjs +0 -81
  94. package/runtime/scripts/lib/app-data.mjs +0 -11
  95. package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
  96. package/runtime/scripts/lib/check-format.mjs +0 -7
  97. package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
  98. package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
  99. package/runtime/scripts/lib/git-dir.mjs +0 -120
  100. package/runtime/scripts/lib/guard-command.mjs +0 -24
  101. package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
  102. package/runtime/scripts/lib/json-formats.mjs +0 -34
  103. package/runtime/scripts/lib/managed-json.mjs +0 -51
  104. package/runtime/scripts/lib/menu-parts.mjs +0 -11
  105. package/runtime/scripts/lib/op-gate.mjs +0 -15
  106. package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
  107. package/runtime/scripts/lib/release-version.mjs +0 -134
  108. package/runtime/scripts/lib/runtime-location.mjs +0 -21
  109. package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
  110. package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
  111. package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
  112. package/runtime/scripts/lib/seat-env.mjs +0 -43
  113. package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
  114. package/runtime/scripts/lib/source-package-imports.mjs +0 -111
  115. package/runtime/scripts/lib/tar-files.mjs +0 -39
  116. package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
  117. package/runtime/scripts/lib/worktree-record.mjs +0 -14
  118. package/runtime/scripts/machine/ask-receipts.mjs +0 -113
  119. package/runtime/scripts/machine/evidence-store.mjs +0 -223
  120. package/runtime/scripts/machine/home.mjs +0 -216
  121. package/runtime/scripts/machine/host-lock.mjs +0 -184
  122. package/runtime/scripts/machine/lock-recovery.mjs +0 -55
  123. package/runtime/scripts/machine/roles-contract.mjs +0 -90
  124. package/runtime/scripts/machine/runtime-change.mjs +0 -20
  125. package/runtime/scripts/machine/workflow-tree.mjs +0 -122
  126. package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
  127. package/runtime/scripts/reconciler/guard-row.mjs +0 -25
  128. package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
  129. package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
  130. package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
  131. package/runtime/scripts/work/brand/brand.mjs +0 -892
  132. package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
  133. package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
  134. package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
  135. package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
  136. package/runtime/scripts/work/draw/draw-source.mjs +0 -412
  137. package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
  138. package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
  139. package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
  140. package/runtime/scripts/work/draw-grammar.mjs +0 -175
  141. package/runtime/scripts/work/draw-render-page.mjs +0 -188
  142. package/runtime/scripts/work/draw-render.mjs +0 -626
  143. package/runtime/scripts/work/png.mjs +0 -237
  144. package/runtime/scripts/work/render-tools.mjs +0 -64
@@ -1,636 +0,0 @@
1
- // gate.mjs - THE gate of a code-writing op and of the Kernel landing (schema starci/gate@1).
2
- //
3
- // starci gate run --root <app> [--base <commit>] [--main <ref>] [--changed <file>...] [--tests <pattern>] [--out <file>]
4
- //
5
- // An op forces it every round of its READ-CODE-CHECK-FIX-REPORT loop (knowledge/op-gate.yaml) and attaches the JSON it prints;
6
- // `starci kernel settle` re-reads that JSON (scripts/kernel/gate-settle.mjs), and the Kernel's landing re-runs `starci gate run` on the op branch
7
- // against its base, so a branch green in the op is green at landing. Over the changed files of the app at --root:
8
- // 1. `starci app lint --changed <files> --format json` at the app root (starci/lint@1: ESLint only on the changed files - the BE canon
9
- // under be/, the FE canon under fe/ - plus the repository checks on them);
10
- // 2. before any tsc: the root `codegen` script and the build of every workspace package that exposes a `dist` export
11
- // (each skipped while its inputs are unchanged since its last run in this worktree);
12
- // 3. tsc through owning configs and their residual root groups, preserving ancestor diagnostics and app compiler context,
13
- // through the compiler
14
- // API, its buildinfo in this worktree's git dir; the worktree's own stale *.tsbuildinfo files are deleted first. Both the
15
- // head and the base program are BOUNDED to the project's app root (appRootOf: the root of hfs.json of kind app, else the
16
- // nearest lockfile root): their host answers no path above it except TypeScript's own lib directory, and realpath is the
17
- // identity (preserveSymlinks), so a junctioned install inside the app stays inside it. An app nested in another repository
18
- // (the runtime's examples inside the host repo) never resolves the enclosing node_modules: an import the app does not
19
- // install is TS2307. An app root (or side) with no node_modules is a tool that could not run (exit 2,
20
- // GATE_INSTALL_MISSING), never measured; the installed canon below is resolved inside the same app root;
21
- // 4. with --tests, the slice's unit/integration specs (jest --maxWorkers=2);
22
- // 0. first, the INSTALLED CANON: for every profile of modules/models/code-patterns.yaml, the canon package its pin's side (be/, fe/)
23
- // resolves must carry the bound canon.version and canon.contentDigest (scripts/gates/canon-digest.mjs over the installed
24
- // files); a mismatch is a finding `canon/installed-canon-mismatch` (never preexisting), a canon that does not resolve is a
25
- // tool that could not run (exit 2, CANON_INSTALL_MISSING), never a pass;
26
- // 0. then the MERGE GUARD: every merge commit in base..HEAD with one parent on the main line (--main, default main|master) is
27
- // recomputed with `git merge-tree`; a path main changed whose merged blob is the lane's (main's change dropped, merge
28
- // 9958cce38) is a finding `merge/dropped-main-change`, never preexisting.
29
- // Only NEW findings block: lint per (file, engine/rule) count and tsc per normalised message are compared with the base commit
30
- // (--base; in a workflow worktree the workflow's previous checkpoint; else the merge-base of HEAD with its upstream, else with main). The base is measured READ-ONLY from git objects: an
31
- // ESLint lintText of the base blob, the repository checks over the base listing, and a TypeScript program whose host reads the
32
- // changed files from the base. No worktree, no checkout and no junction is ever created for it (node_modules incident
33
- // 2026-09-30). A finding the base already has is counted as preexisting, never a finding of the op. Failing specs always block.
34
- // Exit 0 clean, 1 new findings, 2 a tool could not run (never a pass). stdout is the one JSON document.
35
- //
36
- // starci gate run --scope docs [--tree <app>/.starciwork] [--out <file>]
37
- //
38
- // The DOCUMENT profile (knowledge/op-gate.yaml docChecks, owed by docs.author, knowledge.repair and work.author): every document
39
- // check runs from the runtime root - doc-language, check-work-surfaces, check-work-deep, check-example-work, check-contract-cites,
40
- // check-json-exceptions - the `tree` ones over the Work root --tree names (else the runtime's examples). A check that exits 1 is a
41
- // finding (engine doc, rule the check id, its refusal lines); any other exit is a tool that could not run. The report carries
42
- // `profile: docs`; `starci kernel settle` refuses a documenting op's done on a red or unrunnable document gate.
43
- import fs from 'node:fs';
44
- import path from 'node:path';
45
- import { createRequire } from 'node:module';
46
- import { runNpm } from '../api/npm/run-npm.mjs';
47
- import { fileURLToPath, pathToFileURL } from 'node:url';
48
- import { show as gitShow } from '../api/git/show.mjs'; import { mergeTree } from '../api/git/merge-tree.mjs'; import { revParseQuery } from '../api/git/rev-parse-query.mjs'; import { diff as gitDiff } from '../api/git/diff.mjs'; import { lsTree } from '../api/git/ls-tree.mjs'; import { statusQuery as gitStatus } from '../api/git/status-query.mjs'; import { revList } from '../api/git/rev-list.mjs'; import { mergeBase as mergeBaseOf } from '../api/git/merge-base.mjs'; import { isAncestor } from '../api/git/is-ancestor.mjs'; import { eachInOrder } from '../lib/in-order.mjs';
49
- import { pathKey, posixPath, sameResolvedPath } from '../lib/path-key.mjs';
50
- import { hfsEntry } from '../lib/package-at.mjs';
51
- import { readJsonFile } from '../lib/json.mjs';
52
- import { APP_SCOPE, HFS_DECLARATION_FILE, locateDeclaration } from '../hfs/slots.mjs';
53
- import { runNode } from '../api/node/run-node.mjs';
54
- import { sha256 } from '../../engine/digest.mjs';
55
- import { parseYaml } from '../../engine/yaml.mjs';
56
- import { CANON_INSTALL_CODES, PROFILES_FILE, inspectInstalledCanons, loadPins } from './canon-pins.mjs';
57
- import { gateGit as git, gateGitText as gitText, resolveGateBase, gateDelta, gateInputSnapshot } from './gate-input.mjs';
58
- export { resolveGateBase, gateInputSnapshot } from './gate-input.mjs';
59
- import { jestRunError, reduceJest } from './test-world-run.mjs';
60
- import { exposesDist, inputStamp, readTypeProject, removeStaleBuildInfo, typeImpact, workspaceDirs } from './type-impact.mjs';
61
- import { boundedSys, headTsc, baseTsc } from './type-program.mjs';
62
- export { boundedSys, headTsc } from './type-program.mjs';
63
- export const GATE_SCHEMA = 'starci/gate@1';
64
- export const LINT_SCHEMA = 'starci/lint@1';
65
- export const GATE_EXIT = Object.freeze({ clean: 0, findings: 1, toolFailed: 2 });
66
- const runtimeRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
67
- const ESLINT_CONFIGS = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts'];
68
- const JEST_CONFIGS = ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'];
69
- const LINT_CHUNK = 150;
70
- const LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'];
71
- /** A tsc project whose app root (or side) has no install, or no typescript inside it: never measured (exit 2). */
72
- const INSTALL_MISSING = 'GATE_INSTALL_MISSING';
73
- const LISTED_MAX = 500;
74
- const USAGE = 'usage: starci gate run --root <app> [--base <commit>] [--main <ref>] [--changed <file>...] [--tests <pattern>] [--out <file>] | starci gate run --scope docs [--tree <work root>] [--out <file>]';
75
- export const DOC_PROFILE = 'docs';
76
- const GATE_PROFILES = Object.freeze(['code', DOC_PROFILE]);
77
-
78
- const GATE_VALUE_FLAGS = new Set(['--root', '--base', '--main', '--tests', '--out', '--tree', '--scope']);
79
- /** The flags; `--changed` adds named files to the actual base-to-working-tree delta and never narrows it. */
80
- export function parseGateArgs(argv) {
81
- const opts = { root: null, base: null, main: null, changed: null, tests: null, out: null, profile: 'code', tree: null };
82
- let i = 0;
83
- while (i < argv.length) {
84
- const arg = argv[i++];
85
- if (arg === '--changed') {
86
- opts.changed = [];
87
- while (argv[i]?.startsWith('--') === false) opts.changed.push(argv[i++]);
88
- continue;
89
- }
90
- if (!GATE_VALUE_FLAGS.has(arg)) throw new Error(`unknown argument ${arg}; ${USAGE}`);
91
- if (argv[i] === undefined || argv[i].startsWith('--')) throw new Error(`${arg} needs a value; ${USAGE}`);
92
- opts[arg === '--scope' ? 'profile' : arg.slice(2)] = argv[i++];
93
- }
94
- if (!GATE_PROFILES.includes(opts.profile)) throw new Error(`--scope must be one of ${GATE_PROFILES.join(', ')}; ${USAGE}`);
95
- if (opts.tree && opts.profile !== DOC_PROFILE) throw new Error(`--tree belongs to --scope ${DOC_PROFILE}; ${USAGE}`);
96
- return opts;
97
- }
98
- const lines = (text) => String(text ?? '').split(/\r?\n/).filter(Boolean);
99
-
100
- /** The base path a head path is measured against: its rename source, itself, or null when the base has no such file. */
101
- const basePathOf = (delta, rel) => (delta.added.has(rel) ? null : delta.renamed.get(rel) ?? rel);
102
-
103
- /** The base blob of a root-relative path, or null when the base has no such file. */
104
- function baseBlobReader(root, base) {
105
- const prefix = (gitText(revParseQuery, root, ['--show-prefix']) ?? '').trim();
106
- const memo = new Map();
107
- return (rel) => {
108
- if (!memo.has(rel)) {
109
- const r = git(gitShow, root, [`${base}:${prefix}${rel}`], { encoding: 'buffer' });
110
- memo.set(rel, !r.error && r.status === 0 ? r.stdout.toString('utf8') : null);
111
- }
112
- return memo.get(rel);
113
- };
114
- }
115
- /** The cache directory of this worktree (its git dir, never the checkout) and the shared one of the repository. */
116
- function cacheDirs(root) {
117
- const own = gitText(revParseQuery, root, ['--absolute-git-dir'])?.trim();
118
- const common = gitText(revParseQuery, root, ['--git-common-dir'])?.trim();
119
- if (!own || !common) throw Object.assign(new Error(`${root} is not inside a git repository`), { code: 'GATE_NOT_GIT' });
120
- const worktree = path.join(own, 'starci-gate');
121
- const shared = path.join(path.resolve(root, common), 'starci-gate');
122
- fs.mkdirSync(worktree, { recursive: true });
123
- fs.mkdirSync(shared, { recursive: true });
124
- return { worktree, shared };
125
- }
126
- const readCache = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
127
- const writeCache = (file, value) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(value)); };
128
-
129
- /* ----------------------------------------------------------------------------------------------- lint */
130
- /** The errors and deduplicated findings one lint chunk contributes. */
131
- const judgeLintChunk = (run, chunk, hfs, seen, findings, errors) => {
132
- let report = null;
133
- try { report = JSON.parse(run.stdout); } catch { /* judged below */ }
134
- if (report?.schema !== LINT_SCHEMA) { errors.push(`starci app lint produced no ${LINT_SCHEMA} report (exit ${run.status}): ${String(run.stderr || run.stdout || run.error?.message || '').trim().split('\n')[0]}`); return; }
135
- // A JSON report cannot override the child outcome. Exit 1 measures findings; any interrupted, unknown or other exit
136
- // is a tool failure even when stdout claims a clean report. Keep its findings for diagnosis and the existing base policy.
137
- if (run.error || run.signal || ![0, 1].includes(run.status) || (run.status === 1 && !(report.findings ?? []).length))
138
- errors.push(`starci app lint did not complete (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}`);
139
- // An app implementation that keeps only the last --changed did not lint the rest: that is never a clean lint.
140
- const judged = new Set((Array.isArray(report.changed) ? report.changed : chunk).map(posixPath)), missed = chunk.filter((file) => !judged.has(posixPath(file)));
141
- if (missed.length) errors.push(`starci app lint judged ${chunk.length - missed.length} of ${chunk.length} changed files (not ${missed.slice(0, 3).join(', ')}${missed.length > 3 ? ', ...' : ''}): the @starci/hfs behind ${posixPath(hfs.bin)} keeps only the last --changed; install a current @starci/cli`);
142
- errors.push(...(report.errors ?? []).map((e) => `starci app lint: ${e}`));
143
- for (const finding of report.findings ?? []) {
144
- const id = JSON.stringify([finding.engine, finding.rule, finding.path, finding.line, finding.column, finding.message]);
145
- if (!seen.has(id)) { seen.add(id); findings.push(finding); }
146
- }
147
- };
148
- /** `starci app lint --changed <file>...` (one file per list flag) at the app root, in chunks (the Windows command line); {findings, errors}. */
149
- function runAppLint(root, files, hfs) {
150
- const findings = [], errors = [], seen = new Set();
151
- for (let i = 0; i < files.length; i += LINT_CHUNK) {
152
- const chunk = files.slice(i, i + LINT_CHUNK);
153
- const run = runNode([hfs.bin, 'app', 'lint', ...chunk.flatMap((file) => ['--changed', file]), '--format', 'json'], { cwd: root, maxBuffer: 512 * 1024 * 1024 });
154
- judgeLintChunk(run, chunk, hfs, seen, findings, errors);
155
- }
156
- return { findings, errors };
157
- }
158
- const lintKey = (finding) => `${posixPath(finding.path ?? '')}|${finding.engine}/${finding.rule}`;
159
- const countBy = (items, key) => { const m = new Map(); for (const item of items) { m.set(key(item), (m.get(key(item)) ?? 0) + 1); } return m; };
160
-
161
- /** Head lint findings minus the base's, per (file, engine/rule) count: {fresh[], preexisting}. */
162
- function newLintFindings(head, baseCounts) {
163
- const counts = countBy(head, lintKey);
164
- const grown = new Set([...counts].filter(([key, n]) => n > (baseCounts.get(key) ?? 0)).map(([key]) => key));
165
- const fresh = head.filter((finding) => grown.has(lintKey(finding)));
166
- return { fresh, preexisting: head.length - fresh.length };
167
- }
168
- const nearestWith = (root, file, names) => {
169
- let dir = path.dirname(path.join(root, file));
170
- for (;;) {
171
- if (names.some((name) => fs.existsSync(path.join(dir, name)))) return dir;
172
- if (path.resolve(dir) === path.resolve(root)) return null;
173
- const up = path.dirname(dir);
174
- if (up === dir) return null;
175
- dir = up;
176
- }
177
- };
178
- /**
179
- * ESLint over base blobs, read-only: (basePath, headPath) -> the rule ids of the base blob of basePath linted as headPath through
180
- * the app's own install and the nearest flat config (null when the base has no such file). Cached per (base, paths, config).
181
- */
182
- function baseEslint({ root, base, readBase, cache }) {
183
- const linters = new Map();
184
- return async (basePath, file) => {
185
- const cwd = nearestWith(root, file, ESLINT_CONFIGS) ?? root;
186
- const config = ESLINT_CONFIGS.map((name) => path.join(cwd, name)).find((p) => fs.existsSync(p));
187
- const cacheFile = path.join(cache.shared, 'eslint', sha256(`${base}\0${basePath}\0${file}\0${config ? fs.readFileSync(config, 'utf8') : ''}`) + '.json');
188
- let messages = readCache(cacheFile);
189
- if (!messages) {
190
- const text = readBase(basePath);
191
- if (text === null) return null;
192
- if (!linters.has(cwd)) {
193
- const require = createRequire(path.join(cwd, 'package.json'));
194
- const { ESLint } = await import(pathToFileURL(require.resolve('eslint')).href);
195
- linters.set(cwd, new ESLint({ cwd }));
196
- }
197
- const [result] = await linters.get(cwd).lintText(text, { filePath: path.join(root, file) });
198
- messages = (result?.messages ?? []).map((m) => m.ruleId ?? 'eslint-error');
199
- writeCache(cacheFile, messages);
200
- }
201
- return messages;
202
- };
203
- }
204
- /**
205
- * The ESLint findings of `files` (root-relative) at `base`, read-only from git objects (no tree, no worktree, no link):
206
- * [{file, ruleId}] - what scripts/kernel/settle/canon-parity.mjs compares a slice's remaining canon findings with.
207
- */
208
- export async function baseEslintFindings({ root, base, files }) {
209
- root = path.resolve(root);
210
- const sha = resolveGateBase(root, base);
211
- const atBase = baseEslint({ root, base: sha, readBase: baseBlobReader(root, sha), cache: cacheDirs(root) });
212
- const out = [];
213
- await eachInOrder(new Set(files.map(posixPath)), async (file) => { for (const ruleId of (await atBase(file, file)) ?? []) out.push({ file, ruleId }); });
214
- return out;
215
- }
216
- /**
217
- * The base counts of the lint keys the head reports:ESLint over the base blob of each file (lintText through the app's
218
- * own install and the nearest flat config), the repository checks over the base listing. Nothing is written to the tree.
219
- */
220
- async function lintBaseCounts({ root, base, head, delta, hfs, readBase, cache }) {
221
- const counts = new Map();
222
- const add = (key, n = 1) => counts.set(key, (counts.get(key) ?? 0) + n);
223
- const eslintFiles = [...new Set(head.filter((f) => f.engine === 'eslint' && f.path && basePathOf(delta, f.path)).map((f) => f.path))];
224
- const atBase = baseEslint({ root, base, readBase, cache });
225
- await eachInOrder(eslintFiles, async (file) => { for (const rule of (await atBase(basePathOf(delta, file), file)) ?? []) add(`${file}|eslint/${rule}`); });
226
- // The repository checks judge paths: over the base listing (the declaration and file contents are the head's) a finding
227
- // on a file's base path, or on no file, is the base's. A content finding on a changed file does not reproduce there.
228
- const headPathOf = new Map(head.filter((f) => f.engine === 'hfs' && f.path && basePathOf(delta, f.path)).map((f) => [basePathOf(delta, f.path), f.path]));
229
- if (headPathOf.size || head.some((f) => f.engine === 'hfs' && !f.path)) {
230
- const listing = lines(gitText(lsTree, root, ['-r', '--name-only', '--full-tree', base])).map((file) => posixPath(file));
231
- const prefix = (gitText(revParseQuery, root, ['--show-prefix']) ?? '').trim();
232
- const files = listing.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
233
- const { checkRepo } = await import(pathToFileURL(path.join(hfs.dir, 'runtime', 'scripts', 'hfs', 'check.mjs')).href);
234
- const result = checkRepo({ repoRoot: root, files, only: [...headPathOf.keys()], tree: false });
235
- for (const finding of result.findings.filter((f) => f.level === 'error')) {
236
- if (!finding.path) add(`|hfs/${finding.code}`);
237
- else if (headPathOf.has(posixPath(finding.path))) add(`${headPathOf.get(posixPath(finding.path))}|hfs/${finding.code}`);
238
- }
239
- }
240
- return counts;
241
- }
242
-
243
- /* ------------------------------------------------------------------------------------ codegen + build */
244
- const npm = (cwd, script) => runNpm(['run', script], { cwd, maxBuffer: 256 * 1024 * 1024 });
245
- const readManifest = (dir) => readJsonFile(path.join(dir, 'package.json'));
246
-
247
- /** Root codegen, then every dist-exposing workspace package's build; each skipped while its inputs keep their stamp. */
248
- function prepareTypes(root, cache) {
249
- const steps = { codegen: null, build: [] }, errors = [];
250
- const stamps = readCache(path.join(cache.worktree, 'stamps.json')) ?? {};
251
- const manifest = readManifest(root);
252
- if (manifest?.scripts?.codegen) {
253
- const inputs = ['.'];
254
- const stamp = inputStamp(root, inputs);
255
- if (stamps.codegen === stamp) steps.codegen = { ran: false, reason: 'inputs unchanged' };
256
- else {
257
- const started = Date.now(), run = npm(root, 'codegen');
258
- steps.codegen = { ran: true, exit: run.status, ms: Date.now() - started };
259
- if (run.status === 0) stamps.codegen = inputStamp(root, inputs);
260
- else errors.push(`codegen could not run (exit ${run.status}): ${String(run.stderr || run.stdout || run.error?.message || '').trim().split('\n').at(-1)}`);
261
- }
262
- }
263
- for (const dir of workspaceDirs(root, manifest)) prepareBuild(root, dir, stamps, steps, errors);
264
- writeCache(path.join(cache.worktree, 'stamps.json'), stamps);
265
- return { steps, errors };
266
- }
267
- /** One dist-exposing workspace package's build, skipped while its inputs keep their stamp. */
268
- const prepareBuild = (root, dir, stamps, steps, errors) => {
269
- const pkg = readManifest(path.join(root, dir));
270
- if (!exposesDist(pkg) || !pkg?.scripts?.build) return;
271
- const inputs = ['.', `${dir}/dist`], stamp = inputStamp(root, inputs), key = `build:${dir}`;
272
- if (stamps[key] === stamp && fs.existsSync(path.join(root, dir, 'dist'))) { steps.build.push({ package: pkg.name ?? dir, ran: false }); return; }
273
- const started = Date.now(), run = npm(path.join(root, dir), 'build');
274
- steps.build.push({ package: pkg.name ?? dir, ran: true, exit: run.status, ms: Date.now() - started });
275
- if (run.status === 0) stamps[key] = inputStamp(root, inputs);
276
- else errors.push(`build of ${pkg.name ?? dir} could not run (exit ${run.status}): ${String(run.stderr || run.stdout || run.error?.message || '').trim().split('\n').at(-1)}`);
277
- };
278
-
279
- /* ------------------------------------------------------------------------------------------------ tsc */
280
- const isAppDeclaration = (dir) => readJsonFile(path.join(dir, HFS_DECLARATION_FILE))?.kind === APP_SCOPE;
281
-
282
- /**
283
- * The app root of `project` (the root-relative path of a file in it, e.g. its tsconfig.json): the nearest directory from the
284
- * project up to `root` that holds hfs.json of kind app, else the app whose side folder `root` is, else the nearest lockfile
285
- * root on that walk, else `root`. Nothing a type-check of the project reads may lie above it.
286
- */
287
- export function appRootOf(root, project) {
288
- const top = path.resolve(root);
289
- let lockRoot = null;
290
- for (let dir = path.dirname(path.resolve(top, project)); ; dir = path.dirname(dir)) {
291
- if (isAppDeclaration(dir)) return dir;
292
- if (!lockRoot && LOCKFILES.some((name) => fs.existsSync(path.join(dir, name)))) lockRoot = dir;
293
- if (pathKey(dir) === pathKey(top) || path.dirname(dir) === dir) break;
294
- }
295
- const { appRoot } = locateDeclaration(top);
296
- if (pathKey(appRoot) !== pathKey(top) && isAppDeclaration(appRoot)) return appRoot;
297
- return lockRoot ?? top;
298
- }
299
- /** Whether `project` has an install: a node_modules in its folder or one above it, up to `bound` (the app root or the side). */
300
- function hasInstall(bound, root, project) {
301
- for (let dir = path.dirname(path.resolve(root, project)); ; dir = path.dirname(dir)) {
302
- if (fs.existsSync(path.join(dir, 'node_modules'))) return true;
303
- if (pathKey(dir) === pathKey(bound) || path.dirname(dir) === dir) return false;
304
- }
305
- }
306
- /** The app's own TypeScript: the first node_modules/typescript from the project's folder up to `bound`, never above it. */
307
- function loadTypeScript(bound, root, project) {
308
- for (let dir = path.dirname(path.resolve(root, project)); ; dir = path.dirname(dir)) {
309
- const candidate = path.join(dir, 'node_modules', 'typescript');
310
- if (fs.existsSync(path.join(candidate, 'package.json'))) return createRequire(import.meta.url)(candidate);
311
- if (pathKey(dir) === pathKey(bound) || path.dirname(dir) === dir) break;
312
- }
313
- throw new Error(`${INSTALL_MISSING} typescript is not installed under the app root ${posixPath(path.relative(root, bound)) || '.'}`);
314
- }
315
- /** Head tsc findings minus the base's, a multiset over normalised keys: {fresh[], preexisting}. */
316
- export function newTscFindings(head, baseFindings) {
317
- const remaining = countBy(baseFindings, (f) => f.key);
318
- const fresh = head.filter((f) => { const n = remaining.get(f.key) ?? 0; if (n > 0) { remaining.set(f.key, n - 1); return false; } return true; });
319
- return { fresh, preexisting: head.length - fresh.length };
320
- }
321
-
322
- /* ---------------------------------------------------------------------------------------------- tests */
323
- /** The slice's specs through the app's jest (--maxWorkers=2): {step, findings, error}. */
324
- function runTests(root, pattern, cache) {
325
- const cwd = [root, path.join(root, 'be')].find((dir) => JEST_CONFIGS.some((name) => fs.existsSync(path.join(dir, name))) || readManifest(dir)?.jest) ?? root;
326
- let bin;
327
- try { bin = createRequire(path.join(cwd, 'package.json')).resolve('jest/bin/jest'); } catch { return { step: { pattern }, findings: [], error: `jest is not installed under ${posixPath(cwd)}` }; }
328
- const outputFile = path.join(cache.worktree, 'jest.json');
329
- fs.rmSync(outputFile, { force: true });
330
- const started = Date.now();
331
- const run = runNode([bin, '--maxWorkers=2', '--ci', '--json', `--outputFile=${outputFile}`, pattern], { cwd, maxBuffer: 256 * 1024 * 1024 });
332
- const result = readCache(outputFile);
333
- if (!result) return { step: { pattern, exit: run.status }, findings: [], error: `jest produced no json result (exit ${run.status}): ${String(run.stderr || run.error?.message || '').trim().split('\n').at(-1)}` };
334
- const error = jestRunError(result, run);
335
- const totals = reduceJest(result, root);
336
- if (error) return { step: { pattern, exit: run.status, ...totals }, findings: [], error };
337
- const findings = [];
338
- for (const suite of result.testResults) {
339
- const file = posixPath(path.relative(root, suite.name ?? suite.testFilePath ?? ''));
340
- if (suite.testExecError || (suite.status === 'failed' && !(suite.assertionResults ?? []).some((a) => a.status === 'failed')))
341
- findings.push({ engine: 'test', rule: 'suite-failed', path: file, line: null, message: String(suite.testExecError?.message ?? suite.message ?? 'the suite failed to run').split('\n')[0] });
342
- for (const assertion of (suite.assertionResults ?? []).filter((a) => a.status === 'failed'))
343
- findings.push({ engine: 'test', rule: 'test-failed', path: file, line: assertion.location?.line ?? null, message: assertion.fullName ?? assertion.title });
344
- }
345
- if (totals.skipped > 0) findings.push({ engine: 'test', rule: 'test-skipped', path: null, line: null, message: `${totals.skipped} required test(s) were pending or todo` });
346
- if (!result.numTotalTests && !findings.length) findings.push({ engine: 'test', rule: 'no-test-matched', path: null, line: null, message: `no spec matched --tests ${pattern}` });
347
- return { step: { pattern, cwd: posixPath(path.relative(root, cwd)) || '.', exit: run.status, ...totals, ms: Date.now() - started }, findings, error: null };
348
- }
349
- /** The lint half: `starci app lint --changed` over the files, judged against the base. {step, fresh[], preexisting, errors[]} */
350
- async function lintAgainstBase({ root, base, files, delta, hfs, readBase, cache }) {
351
- const lint = runAppLint(root, files, hfs);
352
- const errors = [...lint.errors];
353
- let baseCounts = new Map();
354
- if (!errors.length && lint.findings.length) {
355
- try { baseCounts = await lintBaseCounts({ root, base, head: lint.findings, delta, hfs, readBase, cache }); }
356
- catch (error) { errors.push(`lint base could not be measured: ${String(error?.message ?? error).split('\n')[0]}`); }
357
- }
358
- const judged = newLintFindings(lint.findings, baseCounts);
359
- return { step: { files: files.length, findings: lint.findings.length, new: judged.fresh.length, preexisting: judged.preexisting },
360
- fresh: judged.fresh.map(({ engine, rule, path: file, line, message }) => ({ engine, rule, path: file, line, message })), preexisting: judged.preexisting, errors };
361
- }
362
- /**
363
- * The gate's lint half alone, for a caller that measures TypeScript itself (scripts/kernel/settle/canon-parity.mjs):
364
- * {exit, step, findings[] (new), preexisting, errors[]} over `files` against `base`, with the gate's exit codes.
365
- */
366
- export async function runLintGate({ root, base, files, hfs = null }) {
367
- try {
368
- root = path.resolve(root);
369
- const sha = resolveGateBase(root, base);
370
- const lint = await lintAgainstBase({ root, base: sha, files, delta: gateDelta(root, sha), hfs: hfs ?? hfsEntry(root), readBase: baseBlobReader(root, sha), cache: cacheDirs(root) });
371
- return { exit: [lint.errors.length && GATE_EXIT.toolFailed, lint.fresh.length && GATE_EXIT.findings].find(Boolean) ?? GATE_EXIT.clean, step: lint.step, findings: lint.fresh, preexisting: lint.preexisting, errors: lint.errors };
372
- } catch (error) {
373
- return { exit: GATE_EXIT.toolFailed, step: null, findings: [], preexisting: 0, errors: [String(error?.message ?? error)] };
374
- }
375
- }
376
-
377
- /* ------------------------------------------------------------------------------------------ merge guard */
378
- const GUARD_CHUNK = 200;
379
- /** {path -> blob} of `commit` over `paths` (ls-tree -z, chunked; a missing path has no entry). Full-tree, repository-relative. */
380
- function blobsAt(root, commit, paths) {
381
- const out = new Map();
382
- for (let i = 0; i < paths.length; i += GUARD_CHUNK) {
383
- const text = gitText(lsTree, root, ['-r', '-z', '--full-tree', commit, '--', ...paths.slice(i, i + GUARD_CHUNK)]) ?? '';
384
- for (const row of text.split('\0').filter(Boolean)) { const [meta, file] = row.split('\t'); out.set(posixPath(file), meta.split(' ')[2]); }
385
- }
386
- return out;
387
- }
388
- /**
389
- * The main-side changes one merge commit dropped (owner 2026-10-01, merge 9958cce38 "merge main into lane/ut-int"): the merge
390
- * is recomputed with `git merge-tree --write-tree` from its lane parent and its main parent, and every path the main parent
391
- * changed since their merge-base (main's blob differs from the lane's) whose recorded blob in the merge is the LANE's - the
392
- * lane side taken over main, a clean hunk of main or a conflict resolved as "ours" alike - is a dropped main change. A path
393
- * where the merge kept main's blob, or wrote a third blob (a real resolution), is not. {merge, lane, main, conflicted[],
394
- * dropped: [{path, conflicted, main: blob|null, lane: blob|null}]}; throws when the commits cannot be read.
395
- */
396
- export function droppedMainChanges(root, { merge, mainParent, laneParent }) {
397
- const mergeBase = mergeBaseOf(root, laneParent, mainParent);
398
- if (!mergeBase) throw Object.assign(new Error(`merge ${merge}: its parents ${laneParent} and ${mainParent} have no merge-base`), { code: 'GATE_MERGE_UNREADABLE' });
399
- const remerge = git(mergeTree, root, ['--write-tree', '--name-only', '--no-messages', laneParent, mainParent]);
400
- if (remerge.error || ![0, 1].includes(remerge.status)) throw Object.assign(new Error(`merge ${merge}: git merge-tree could not recompute it: ${String(remerge.stderr ?? remerge.error?.message ?? '').trim().split('\n')[0]}`), { code: 'GATE_MERGE_UNREADABLE' });
401
- const conflicted = lines(remerge.stdout).slice(1).map((file) => posixPath(file));
402
- const touched = lines(gitText(gitDiff, root, ['--no-renames', '--name-only', mergeBase, mainParent])).map((file) => posixPath(file));
403
- const main = blobsAt(root, mainParent, touched), lane = blobsAt(root, laneParent, touched), recorded = blobsAt(root, merge, touched);
404
- const conflicts = new Set(conflicted);
405
- const dropped = touched.filter((file) => (main.get(file) ?? null) !== (lane.get(file) ?? null) && (recorded.get(file) ?? null) === (lane.get(file) ?? null))
406
- .map((file) => ({ path: file, conflicted: conflicts.has(file), main: main.get(file) ?? null, lane: lane.get(file) ?? null }));
407
- return { merge, lane: laneParent, main: mainParent, mergeBase, conflicted, dropped };
408
- }
409
- /** The main line a merge is judged against: `main`, else `master`, else null (then no merge has a main side). */
410
- export function mainTipOf(root, ref = null) {
411
- for (const name of ref ? [ref] : ['main', 'master']) { const sha = gitText(revParseQuery, root, ['--verify', '--quiet', `${name}^{commit}`])?.trim(); if (sha) return sha; }
412
- return null;
413
- }
414
- /**
415
- * THE merge guard: every merge commit in base..head whose parents split into exactly one main-side parent (an ancestor of the
416
- * main tip) and a lane parent is recomputed (droppedMainChanges); a merge with dropped main changes is a finding (engine
417
- * `merge`, rule `dropped-main-change`, one per path), never preexisting. A merge of two lane parents, or of two main parents,
418
- * has no main side and is not judged. {checked: [sha], findings[], errors[]}
419
- */
420
- export function mergeGuard(root, { base, head = 'HEAD', mainTip = mainTipOf(root) }) {
421
- const out = { checked: [], findings: [], errors: [] };
422
- if (!mainTip) return out;
423
- const merges = lines(gitText(revList, root, ['--merges', '--parents', head, `^${base}`]));
424
- for (const row of merges) {
425
- const [merge, ...parents] = row.split(' ');
426
- const onMain = parents.filter((p) => isAncestor(root, p, mainTip));
427
- if (onMain.length !== 1 || parents.length !== 2) continue;
428
- const mainParent = onMain[0], laneParent = parents.find((p) => p !== mainParent);
429
- try {
430
- const judged = droppedMainChanges(root, { merge, mainParent, laneParent });
431
- out.checked.push(merge);
432
- for (const d of judged.dropped) out.findings.push({ engine: 'merge', rule: 'dropped-main-change', path: d.path, line: null,
433
- message: `merge ${merge.slice(0, 12)} keeps the lane side of ${d.path} over main's change (${d.conflicted ? 'a conflict resolved as the lane' : 'a clean main hunk dropped'}; main parent ${mainParent.slice(0, 12)}, recomputed with git merge-tree)` });
434
- } catch (error) { out.errors.push(String(error?.message ?? error)); }
435
- }
436
- return out;
437
- }
438
-
439
- /* ----------------------------------------------------------------------------------------- installed canon */
440
- /**
441
- * Every profile's canon as installed under the app at `root`, judged against its binding in `runtime`'s code-patterns.yaml:
442
- * the package resolved from the pin's side directory must have the bound version and content digest.
443
- * {checked: [{profile, package, side, path, version, digest, files}], findings[], errors[]}
444
- */
445
- export function installedCanonFindings(root, { runtime = runtimeRoot } = {}) {
446
- const out = { checked: [], findings: [], errors: [] };
447
- let pins, profiles;
448
- try {
449
- pins = loadPins(runtime).pins ?? {};
450
- profiles = parseYaml(fs.readFileSync(path.join(runtime, PROFILES_FILE), 'utf8'))?.profiles ?? {};
451
- } catch (error) { out.errors.push(`${CANON_INSTALL_CODES.unjudged} the canon bindings are unreadable: ${error.message}`); return out; }
452
- return inspectInstalledCanons(root, { pins, profiles, bound: appRootOf(root, 'package.json') });
453
- }
454
-
455
- /* ----------------------------------------------------------------------------------------------- gate */
456
- /** The op-binding's owned scope for this root (and the default base it implies); throws when out of scope. */
457
- const gateOwned = (context, root, takeBase) => {
458
- const binding = context?.gateBinding;
459
- if (!binding) return null;
460
- const target = binding.targets?.find((row) => sameResolvedPath(row.root, root));
461
- if (!target?.head || !target.owned?.length) throw new Error('the gate target is not in the admitted job scope');
462
- takeBase(target.head);
463
- return target.owned;
464
- };
465
- /** The installed canon and the merge guard: findings that never count as preexisting. */
466
- const runStaticChecks = (root, report, main, fresh) => {
467
- // The installed canon: the lint below means nothing over a canon that is not the bound one.
468
- const installed = installedCanonFindings(root);
469
- report.steps.canon = installed.checked;
470
- report.errors.push(...installed.errors);
471
- fresh.push(...installed.findings);
472
- // The merge guard first: a merge in base..HEAD that took the lane side over a main-side change is never a clean branch.
473
- const guard = mergeGuard(root, { base: report.base, mainTip: mainTipOf(root, main) });
474
- report.steps.merges = { checked: guard.checked, dropped: guard.findings.length };
475
- report.errors.push(...guard.errors);
476
- fresh.push(...guard.findings);
477
- };
478
- /** The lint step over the changed files; returns its preexisting count. */
479
- const runLintStep = async (root, report, { delta, hfs, readBase, cache }, fresh) => {
480
- if (!report.changed.length) return 0;
481
- const lint = await lintAgainstBase({ root, base: report.base, files: report.changed, delta, hfs: hfs ?? hfsEntry(root), readBase, cache });
482
- report.errors.push(...lint.errors);
483
- report.steps.lint = lint.step;
484
- fresh.push(...lint.fresh);
485
- return lint.preexisting;
486
- };
487
- /** One project's head tsc against its base; tool errors land in report.errors, never thrown. */
488
- const tscProject = (root, project, report, { cache, delta, readBase, typescript, required }, fresh) => {
489
- const started = Date.now();
490
- // No install, no measurement: a program over an app with no node_modules would only count its missing imports.
491
- const bound = appRootOf(root, project);
492
- if (!hasInstall(bound, root, project)) {
493
- report.errors.push(`${INSTALL_MISSING} tsc cannot measure ${project}: its app root ${posixPath(path.relative(root, bound)) || '.'} (or its side) has no install (node_modules); install it and re-run`);
494
- return 0;
495
- }
496
- try {
497
- const ts = typescript ?? loadTypeScript(bound, root, project);
498
- const head = headTsc(ts, root, project, { bound, cache, required });
499
- const baseFindings = head.length ? baseTsc(ts, root, project, { bound, delta, readBase }) : [];
500
- const judged = newTscFindings(head, baseFindings);
501
- report.steps.tsc.push({ project, errors: head.length, new: judged.fresh.length, preexisting: judged.preexisting, ms: Date.now() - started });
502
- fresh.push(...judged.fresh.map(({ key, ...finding }) => finding));
503
- return judged.preexisting;
504
- } catch (error) { report.errors.push(`tsc could not run on ${project}: ${String(error?.message ?? error).split('\n')[0]}`); return 0; }
505
- };
506
- /** The impact-driven tsc steps: stale buildinfo, codegen/build prerequisites, then one bounded program per project. */
507
- const runTscSteps = (root, report, { cache, delta, readBase, typescript }, fresh) => {
508
- let impact;
509
- const readProject = (project) => {
510
- const bound = appRootOf(root, project), ts = typescript ?? loadTypeScript(bound, root, project);
511
- return readTypeProject(ts, root, project, boundedSys(ts, bound));
512
- };
513
- try { impact = typeImpact(root, report.inputs.map((file) => file.path), { deleted: report.inputs.filter((file) => file.sha256 === null).map((file) => file.path), readProject }); }
514
- catch (error) { impact = { projects: [], required: new Map(), errors: [`tsc impact could not be measured: ${error.message}`] }; }
515
- report.errors.push(...impact.errors);
516
- if (!impact.projects.length || impact.errors.length) return 0;
517
- report.steps.staleBuildInfo = removeStaleBuildInfo(root);
518
- let prepared;
519
- try { prepared = prepareTypes(root, cache); }
520
- catch (error) { prepared = { steps: { codegen: null, build: [] }, errors: [`type prerequisites could not be measured: ${error.message}`] }; }
521
- report.steps.codegen = prepared.steps.codegen;
522
- report.steps.build = prepared.steps.build;
523
- report.errors.push(...prepared.errors);
524
- let preexisting = 0;
525
- if (!prepared.errors.length) for (const project of impact.projects) {
526
- preexisting += tscProject(root, project, report, { cache, delta, readBase, typescript, required: impact.required.get(project) }, fresh);
527
- }
528
- return preexisting;
529
- };
530
- /** The post-run input check: a slice that moved during CHECK is rerun, never admitted. */
531
- const verifyGateInputs = (root, report, changed, owned) => {
532
- try {
533
- const after = gateInputSnapshot(root, report.base, changed ?? [], owned);
534
- if (JSON.stringify(after.inputs) !== JSON.stringify(report.inputs) || !isAncestor(root, report.head, after.head))
535
- report.errors.push('the gate inputs changed during CHECK; rerun over the current slice');
536
- } catch (error) { report.errors.push(String(error?.message ?? error)); }
537
- };
538
- /**
539
- * Run the gate; resolves the starci/gate@1 report. Never throws for a tool failure: it lands in errors[] with exit 2.
540
- * Spec seams: `hfs` ({dir, bin}) the starci CLI to lint with and its @starci/hfs, default hfsEntry(root); `ts` the TypeScript module, default
541
- * the app's own install per project; `context` the op context (its gateBinding scopes the run), injected by the entry (scripts/cli/gate-run.mjs), default none.
542
- */
543
- export async function runGate({ root, base = null, changed = null, tests = null, main = null, hfs = null, ts: typescript = null,
544
- context = null }) {
545
- const at = new Date().toISOString();
546
- const report = { schema: GATE_SCHEMA, at, root: posixPath(path.resolve(root)), base: null, head: null, dirty: null, changed: [], exit: GATE_EXIT.toolFailed, ok: false,
547
- steps: { canon: null, merges: null, lint: null, codegen: null, build: [], tsc: [], tests: null, staleBuildInfo: [] }, inputs: [], counts: { new: 0, preexisting: 0 }, findings: [], errors: [] };
548
- const fail = (error) => { report.errors.push(String(error?.message ?? error)); return finish(report); };
549
- let cache, delta, owned = null;
550
- try {
551
- root = path.resolve(root);
552
- owned = gateOwned(context, root, (head) => { base ??= head; });
553
- report.base = resolveGateBase(root, base);
554
- report.head = gitText(revParseQuery, root, ['HEAD'])?.trim() ?? null;
555
- const status = gitText(gitStatus, root, ['--porcelain']);
556
- if (status === null) throw new Error('the gate working-tree status could not be read');
557
- report.dirty = Boolean(status.trim());
558
- cache = cacheDirs(root);
559
- delta = gateDelta(root, report.base);
560
- } catch (error) { return fail(error); }
561
- try {
562
- const snapshot = gateInputSnapshot(root, report.base, changed ?? [], owned);
563
- report.head = snapshot.head;
564
- report.changed = snapshot.changed;
565
- report.inputs = snapshot.inputs;
566
- } catch (error) { return fail(error); }
567
- const readBase = baseBlobReader(root, report.base);
568
- const fresh = [];
569
- let preexisting = 0;
570
- runStaticChecks(root, report, main, fresh);
571
- preexisting += await runLintStep(root, report, { delta, hfs, readBase, cache }, fresh);
572
- preexisting += runTscSteps(root, report, { cache, delta, readBase, typescript }, fresh);
573
- if (tests) {
574
- const tested = runTests(root, tests, cache);
575
- report.steps.tests = tested.step;
576
- if (tested.error) report.errors.push(tested.error);
577
- fresh.push(...tested.findings);
578
- }
579
- report.counts = { new: fresh.length, preexisting };
580
- report.findings = fresh.slice(0, LISTED_MAX);
581
- verifyGateInputs(root, report, changed, owned);
582
- return finish(report);
583
- }
584
- function finish(report) {
585
- report.exit = [report.errors.length && GATE_EXIT.toolFailed, report.counts.new && GATE_EXIT.findings].find(Boolean) ?? GATE_EXIT.clean;
586
- report.ok = report.exit === GATE_EXIT.clean;
587
- return report;
588
- }
589
- /** The document checks of op-gate.yaml docChecks: [{id, script, tree}]. */
590
- function docChecksOf(runtime = runtimeRoot) {
591
- const doc = parseYaml(fs.readFileSync(path.join(runtime, 'knowledge', 'op-gate.yaml'), 'utf8'));
592
- return (doc?.docChecks ?? []).map((c) => ({ id: String(c.id), script: String(c.script), tree: c.tree === true }));
593
- }
594
- const DOC_PATH = /^(?:REFUSED?\s+)?([^\s:]+\.(?:md|ya?ml|mjs|json)):(\d+)/;
595
- /** The lines a document check prints for its refusals: REFUSE/REFUSED or file:line lines when it has them, else its last lines. */
596
- function refusalLines(output) {
597
- const all = String(output ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
598
- const refused = all.filter((l) => /^REFUSED?\b/.test(l) || DOC_PATH.test(l));
599
- return (refused.length ? refused : all.slice(-5)).slice(0, 50);
600
- }
601
- /** One doc check's run: its step row, its tool errors, its refusal findings. */
602
- const runDocCheck = (check, { runtime, tree, spawn, report, fresh }) => {
603
- const args = [path.join(runtime, check.script), ...(check.tree && tree ? ['--tree', path.resolve(tree)] : [])];
604
- const started = Date.now();
605
- const run = spawn(args, { cwd: runtime, maxBuffer: 256 * 1024 * 1024 });
606
- report.steps.docs.push({ id: check.id, command: `node ${check.script}${check.tree && tree ? ' --tree <tree>' : ''}`, exit: run.status ?? null, ms: Date.now() - started });
607
- // An uncaught exception also exits 1: a stack trace on stderr is a check that could not run, never its findings.
608
- const crashed = run.status === 1 && String(run.stderr ?? '').split('\n').some((l) => /^\s+at /.test(l) && /[:(]\d+:\d+\)?$/.test(l));
609
- if (run.error || crashed || (run.status !== 0 && run.status !== 1)) {
610
- const reason = String(run.stderr ?? '').split(/\r?\n/).find((l) => /Error/.test(l))?.trim() ?? '';
611
- report.errors.push(`${check.id} could not run (exit ${run.status ?? run.error?.message})${reason ? ': ' + reason : ''}`);
612
- return;
613
- }
614
- if (run.status === 1) {
615
- for (const message of refusalLines(`${run.stdout ?? ''}\n${run.stderr ?? ''}`)) {
616
- const at = DOC_PATH.exec(message);
617
- fresh.push({ engine: 'doc', rule: check.id, path: at?.[1] ?? null, line: at ? Number(at[2]) : null, message });
618
- }
619
- }
620
- };
621
- /**
622
- * The document profile: each docChecks script run from the runtime root (`tree` ones with --tree when given). Exit 1 of a check
623
- * is its findings, any other exit (or a spawn error) a tool that could not run. The same starci/gate@1 envelope, profile docs.
624
- */
625
- export function runDocGate({ tree = null, runtime = runtimeRoot, checks = docChecksOf(runtime), spawn = runNode } = {}) {
626
- const report = { schema: GATE_SCHEMA, profile: DOC_PROFILE, at: new Date().toISOString(), root: posixPath(path.resolve(runtime)), tree: tree ? posixPath(path.resolve(tree)) : null,
627
- base: null, head: gitText(revParseQuery, runtime, ['HEAD'])?.trim() ?? null, changed: [], exit: GATE_EXIT.toolFailed, ok: false,
628
- steps: { docs: [] }, counts: { new: 0, preexisting: 0 }, findings: [], errors: [] };
629
- if (tree && !fs.existsSync(path.resolve(tree))) { report.errors.push(`--tree ${tree} does not exist`); return finish(report); }
630
- if (!checks.length) { report.errors.push('knowledge/op-gate.yaml names no docChecks'); return finish(report); }
631
- const fresh = [];
632
- for (const check of checks) runDocCheck(check, { runtime, tree, spawn, report, fresh });
633
- report.counts = { new: fresh.length, preexisting: 0 };
634
- report.findings = fresh.slice(0, LISTED_MAX);
635
- return finish(report);
636
- }