@starci/hfs 5.0.7 → 5.0.8

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 (62) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +7 -8
  3. package/cache/fake-session.mjs +1 -1
  4. package/cache/hfs-family.mjs +22 -9
  5. package/cache/import-graph.d.mts +32 -4
  6. package/cache/import-graph.mjs +143 -11
  7. package/cache/lint-family.d.mts +0 -2
  8. package/cache/lint-family.mjs +54 -33
  9. package/cache/records.d.mts +7 -0
  10. package/cache/records.mjs +17 -1
  11. package/cache/run.d.mts +1 -1
  12. package/cache/session.mjs +6 -2
  13. package/cache/side-key.d.mts +2 -0
  14. package/cache/side-key.mjs +5 -1
  15. package/cache/structure-memo.d.mts +34 -0
  16. package/cache/structure-memo.mjs +150 -0
  17. package/cache/types.d.mts +6 -2
  18. package/cache/wiring-family.mjs +1 -1
  19. package/codemod/small-renames.mjs +22 -2
  20. package/lint/eslint-list.cjs +26 -0
  21. package/lint/run.d.mts +18 -58
  22. package/lint/run.mjs +54 -284
  23. package/package.json +1 -1
  24. package/release/lint-report.d.mts +1 -1
  25. package/release/lint-report.mjs +3 -3
  26. package/report/sonar.d.mts +1 -1
  27. package/report/sonar.mjs +3 -3
  28. package/report/stylelint-findings.d.mts +23 -0
  29. package/report/stylelint-findings.mjs +9 -0
  30. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  31. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  32. package/runtime/knowledge/hfs/rules.yaml +4 -4
  33. package/runtime/knowledge/hfs/slots.yaml +1 -1
  34. package/runtime/knowledge/sonar-gate.yaml +2 -2
  35. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  36. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  37. package/runtime/scripts/hfs/check.d.mts +16 -1
  38. package/runtime/scripts/hfs/check.mjs +18 -7
  39. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  40. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  41. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  42. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  43. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  44. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  46. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  47. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  48. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  49. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  50. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  51. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  52. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  53. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  54. package/scaffold/app.mjs +3 -3
  55. package/src/check-cache-structure-support.d.mts +52 -0
  56. package/src/main.d.mts +3 -2
  57. package/src/main.mjs +123 -109
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +19 -20
  60. package/sync/index.mjs +2 -2
  61. package/templates/app/package-scripts/core.json +2 -2
  62. package/templates/app/task-graph/turbo.json +0 -3
package/lint/run.d.mts CHANGED
@@ -2,33 +2,27 @@ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
2
2
  import { type CanonDeclarations, type LintSideStats } from '../cache/lint-family.mjs';
3
3
  import type { CheckSession } from '../cache/types.mjs';
4
4
  type Dynamic = any;
5
- /** The parsed arguments of the lint verb. */
6
- export interface LintOptions {
7
- changed: string[] | null;
8
- fix: boolean;
9
- format: string;
10
- out: string | undefined;
11
- workspace: string | undefined;
12
- side?: string;
13
- full?: boolean;
14
- }
15
- /** The cache a lint run may use: the session of the check cache. The caller flushes it after the run. */
16
- export interface LintCache {
17
- session: CheckSession;
18
- }
19
5
  /** What a linter run answers: its json results, or why it could not complete (both when the run was partial). */
20
6
  export interface LinterRun {
21
7
  results?: Dynamic[];
22
8
  error?: string;
23
9
  }
24
- /** The seam that runs a linter through the app's own install from cwd. */
25
- export type LinterRunner = (spec: {
10
+ /**
11
+ * What a linter run is asked for. `list` is the files an ESLint run lints (cwd-relative, posix) when they are named one by one: they go to the child
12
+ * through a file, never the command line, and `args` then holds the options alone. `files` is the number of source files the run judges, which the
13
+ * heavy-command budget of the ESLint kind scales with (a list names its own count).
14
+ */
15
+ export interface LinterSpec {
26
16
  cwd: string;
27
17
  pkg: string;
28
18
  bin: string;
29
19
  args: string[];
30
20
  bound?: string | null;
31
- }) => Promise<LinterRun>;
21
+ list?: readonly string[] | undefined;
22
+ files?: number | undefined;
23
+ }
24
+ /** The seam that runs a linter through the app's own install from cwd. */
25
+ export type LinterRunner = (spec: LinterSpec) => Promise<LinterRun>;
32
26
  /** One finding of the lint report. */
33
27
  export interface LintFinding {
34
28
  engine: string;
@@ -39,17 +33,9 @@ export interface LintFinding {
39
33
  line: number | null;
40
34
  column: number | null;
41
35
  message: string;
42
- titleVi?: string;
43
- nextStepVi?: string;
44
36
  }
45
37
  /** The app declaration the slot machine returns (readRepoDeclaration): sides are present at an app root. */
46
38
  type RepoDeclaration = Dynamic;
47
- export declare const LINT_SCHEMA = "starci/lint@1";
48
- /** The app-relative workspace of `opts.workspace` (already app-relative), or a refusal when it is not one folder of fe/apps or fe/packages. */
49
- export declare function workspaceOf(workspace: string): string;
50
- /** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
51
- * catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
52
- export declare function parseLintArgs(argv: readonly string[]): LintOptions;
53
39
  /**
54
40
  * The heap of the ESLint child in MiB: the starci CLI sets it from its one owner, the eslint memory of ops/liveness.yaml `heavy` (a typed whole-side
55
41
  * lint of a large app outgrows node's default heap). Unset, ESLint runs on node's default.
@@ -57,13 +43,7 @@ export declare function parseLintArgs(argv: readonly string[]): LintOptions;
57
43
  export declare const ESLINT_HEAP_ENV = "STARCI_ESLINT_HEAP_MB";
58
44
  export declare function eslintHeapArgs(env?: NodeJS.ProcessEnv): string[];
59
45
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
60
- export declare function runLinter({ cwd, pkg, bin, args, bound }: {
61
- cwd: string;
62
- pkg: string;
63
- bin: string;
64
- args: string[];
65
- bound?: string | null;
66
- }, execute?: HeavyNodeRun): Promise<LinterRun>;
46
+ export declare function runLinter({ cwd, pkg, bin, args, bound, list, files }: LinterSpec, execute?: HeavyNodeRun): Promise<LinterRun>;
67
47
  /**
68
48
  * The lint scope a flat config declares: `ignored(file)` for its global ignores (a block holding only `ignores` and `name`), which prune
69
49
  * the walk, and `source(file)` for a file some block's `files` select while that block's own `ignores` do not (standard flat-config
@@ -85,8 +65,8 @@ export declare function installedLintConfig({ repoRoot, cwd, side }: {
85
65
  cwd: string;
86
66
  side: string;
87
67
  }): Promise<Dynamic[]>;
88
- /** Execute each side's installed ESLint config once; check and lint share scope, coverage and tool-failure judgment. */
89
- export declare function eslintRepository({ repoRoot, app, opts, changed, existing, workspace, onFe, run }: {
68
+ /** Execute each side's installed ESLint config once, whole or over a named list; the cached and the cacheless run share scope, coverage and tool-failure judgment. */
69
+ export declare function eslintRepository({ repoRoot, app, opts, changed, existing, run, scopes }: {
90
70
  repoRoot: string;
91
71
  app?: RepoDeclaration;
92
72
  opts?: {
@@ -95,9 +75,8 @@ export declare function eslintRepository({ repoRoot, app, opts, changed, existin
95
75
  };
96
76
  changed?: readonly string[] | null;
97
77
  existing?: readonly string[];
98
- workspace?: string | null;
99
- onFe?: string | null;
100
78
  run?: LinterRunner | undefined;
79
+ scopes?: Readonly<Record<string, readonly string[]>> | undefined;
101
80
  }): Promise<{
102
81
  findings: LintFinding[];
103
82
  errors: string[];
@@ -138,30 +117,11 @@ export interface CachedLinters {
138
117
  * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
139
118
  * results, so the lint report is built the same way from either.
140
119
  */
141
- export declare function cachedLinters({ repoRoot, app, opts, workspace, onFe, run, cache }: {
120
+ export declare function cachedLinters({ repoRoot, app, side: only, run, session }: {
142
121
  repoRoot: string;
143
122
  app: RepoDeclaration;
144
- opts: LintOptions;
145
- workspace: string | null;
146
- onFe: string | null;
123
+ side?: string | undefined;
147
124
  run?: LinterRunner | undefined;
148
- cache: LintCache;
125
+ session: CheckSession;
149
126
  }): Promise<CachedLinters>;
150
- /**
151
- * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
152
- * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
153
- */
154
- export declare function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles, runLinter: linter, cache }: {
155
- repoRoot: string;
156
- opts: LintOptions;
157
- hfsCheck: (repoRoot: string) => Dynamic;
158
- trackedFiles?: (repoRoot: string) => string[];
159
- runLinter?: LinterRunner | undefined;
160
- cache?: LintCache | undefined;
161
- }): Promise<{
162
- report: Dynamic;
163
- sonar: Dynamic;
164
- exit: number;
165
- }>;
166
- export declare function printLintText(report: Dynamic, out: (text: string) => unknown): void;
167
127
  export {};
package/lint/run.mjs CHANGED
@@ -6,107 +6,31 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- // starci app lint - the ONE lint entry and the ONE report of a StarCi app.
9
+ // The linters of `starci app check --gate lint`: ESLint once per side and stylelint over the fe stylesheets, through the app's own install.
10
10
  //
11
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--json] [--out <file>]
12
- //
13
- // It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
14
- // 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
15
- // fe/ the fe canon: the per-file rules and the project-graph rules of the architecture machine, findings on the line of the
16
- // offending TypeScript file); each side lints only its own files, so the be rules never see fe/ and the fe rules never see be/;
17
- // 2. `starci app check` for what has no TypeScript file to sit on (the tree, managed files, pins, CI, contracts, docs, .starciwork), root and sides;
18
- // 3. stylelint over the fe side's stylesheets (sync STYLE_GLOB), from fe/ with its stylelint.config.mjs.
19
- // Their findings become one list of one shape (`starci/lint@1`), every path app-relative. `--json` prints that report on stdout;
20
- // `--out <file>` writes the same findings as THE Sonar Generic Issue Import file (engine ids starci-hfs, eslint, stylelint in one
21
- // document); the exit code is the land-gate input: 0 no finding, 1 at least one, 2 a tool could not run (never a pass).
22
- // --changed restricts ESLint and stylelint to the listed files and keeps of the app findings only those on a listed file or on no file.
23
- // --workspace scopes the same lint to one npm workspace of the fe side (fe/apps/<app> or fe/packages/<pkg>, app-relative): ESLint and
24
- // stylelint over that folder only, and of the app findings only those inside it. It is the `lint` script of every fe workspace (the
25
- // turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
26
- // The result cache (contexts/project/check-cache.md): when the caller hands lintRepository a cache session, ESLint and stylelint judge only what
27
- // the records of `.starcistacks/cache/lint/<side>/` cannot vouch for (cache/lint-family.mjs) - the same records and fingerprint `app check --lint`
28
- // uses, so the two verbs never hold two answers. `--side` reads and writes its side's records, `--workspace` reads and writes none, `--changed`
29
- // and `--fix` bypass the cache, and `--full` judges everything again and rewrites the records.
11
+ // 1. ESLint runs from the side folder with that side's eslint.config.mjs (be/ the be canon, fe/ the fe canon: the per-file rules and the
12
+ // project-graph rules of the architecture machine, findings on the line of the offending TypeScript file); each side lints only its own
13
+ // files, so the be rules never see fe/ and the fe rules never see be/;
14
+ // 2. stylelint judges the fe side's stylesheets (sync STYLE_GLOB) from fe/ with its stylelint.config.mjs.
15
+ // `cachedLinters` is the one entry of `app check`: per side it judges only what the records of `.starcistacks/cache/lint/<side>/` cannot
16
+ // vouch for (cache/lint-family.mjs, contexts/project/check-cache.md), and `eslintRepository` is the launcher it and the cacheless run share.
30
17
  import fs from 'node:fs';
31
18
  import path from 'node:path';
32
19
  import { createRequire } from 'node:module';
20
+ import { makeTempDir } from '../runtime/scripts/api/fs/make-temp-dir.mjs';
33
21
  import { completedNode, isHostHeld, isNodeExecutionRefusal, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
34
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
- import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
36
23
  import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
37
- import { isSide } from '../runtime/scripts/hfs/slot-manifest-shape.mjs';
38
- import { chunkByChars, mapBounded, mergeRuns } from './chunk.mjs';
39
24
  import { STYLE_GLOB } from '../sync/index.mjs';
40
25
  import { byCodeUnit } from '../report/order.mjs';
41
26
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
42
27
  import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
43
- import { gateVerdict } from '../runtime/scripts/hfs/gate-threshold.mjs';
44
28
  import { mergeBase } from '../runtime/scripts/api/git/merge-base.mjs';
45
29
  import { lintFamilyReport, lintSide, loadAppTypeScript } from '../cache/lint-family.mjs';
46
- // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
47
- export const LINT_SCHEMA = 'starci/lint@1';
48
30
  /** The side whose stylesheets stylelint judges. */
49
31
  const STYLE_SIDE = 'fe';
50
32
  const CODE_PREFIX = /^\[([A-Z][A-Z0-9_]+)\] /;
51
33
  const posix = (file) => String(file).replace(/\\/g, '/').replace(/^\.\//, '');
52
- /** An npm workspace of the fe side, app-relative: one folder below fe/apps or fe/packages (the root package.json workspaces). */
53
- const WORKSPACE = /^fe\/(?:apps|packages)\/[a-z0-9][a-z0-9-]*$/;
54
- /** The app-relative workspace of `opts.workspace` (already app-relative), or a refusal when it is not one folder of fe/apps or fe/packages. */
55
- export function workspaceOf(workspace) {
56
- const rel = posix(workspace).replace(/\/+$/, '');
57
- if (!WORKSPACE.test(rel))
58
- throw new Error(`--workspace ${workspace} is not an fe workspace (fe/apps/<app> or fe/packages/<pkg>, from the app root)`);
59
- return rel;
60
- }
61
- /** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
62
- function validateSide(opts) {
63
- if (opts.side === undefined)
64
- return;
65
- if (!isSide(opts.side))
66
- throw new Error('LINT_SIDE_INVALID: --side is be or fe');
67
- if (opts.side === 'be' && opts.workspace !== undefined)
68
- throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
69
- }
70
- /** Side findings stay with their side; findings outside both sides belong to the backend lint. */
71
- function findingOnSide(finding, side) {
72
- if (side === undefined)
73
- return true;
74
- const file = posix(finding.path ?? '');
75
- const owner = SIDES.find((candidate) => file === candidate || file.startsWith(`${candidate}/`));
76
- return (owner ?? 'be') === side;
77
- }
78
- /** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
79
- * catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
80
- export function parseLintArgs(argv) {
81
- const opts = { changed: null, fix: false, format: 'text', out: undefined, workspace: undefined };
82
- for (let i = 0; i < argv.length; i += 1) {
83
- const arg = argv[i];
84
- if (arg === '--changed') {
85
- opts.changed ??= [];
86
- while (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
87
- opts.changed.push(posix(argv[++i]));
88
- }
89
- else if (arg === '--fix')
90
- opts.fix = true;
91
- else if (arg === '--full')
92
- opts.full = true;
93
- else if (arg === '--json')
94
- opts.format = 'json';
95
- else if (['--out', '--workspace', '--side'].includes(arg)) {
96
- if (arg === '--side' && (argv[i + 1] === undefined || argv[i + 1].startsWith('--')))
97
- throw new Error('LINT_SIDE_INVALID: --side needs be or fe');
98
- if (argv[i + 1] === undefined)
99
- throw new Error(`${arg} needs a value`);
100
- opts[arg.slice(2)] = argv[++i];
101
- }
102
- else
103
- throw new Error(`unknown argument ${arg}`);
104
- }
105
- if (opts.workspace !== undefined && opts.changed !== null)
106
- throw new Error('--workspace and --changed do not combine: a workspace lint judges the whole workspace');
107
- validateSide(opts);
108
- return opts;
109
- }
110
34
  /** The typed ESLint run reads nothing above the app root: its TypeScript `sys` is bound by this preload (3.3, bound-sys.cjs). */
111
35
  const BOUND_ENV = 'STARCI_LINT_BOUND';
112
36
  const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
@@ -119,8 +43,10 @@ export function eslintHeapArgs(env = process.env) {
119
43
  const mb = Number(env[ESLINT_HEAP_ENV]);
120
44
  return Number.isInteger(mb) && mb > 0 ? [`--max-old-space-size=${mb}`] : [];
121
45
  }
46
+ /** The ESLint list runner (eslint-list.cts, built beside this file). */
47
+ const LIST_RUNNER = fileURLToPath(new URL('./eslint-list.cjs', import.meta.url));
122
48
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
123
- export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute) {
49
+ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files }, execute) {
124
50
  let entry;
125
51
  try {
126
52
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -131,7 +57,23 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute)
131
57
  return { error: `${pkg} is not installed for ${cwd}` };
132
58
  }
133
59
  const heap = pkg === 'eslint' ? eslintHeapArgs() : [];
134
- const run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app lint ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), entry, ...args], cwd, maxBuffer: 64 * 1024 * 1024, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
60
+ // A named list is written to a file the runner reads: ESLint lints all of it in one process, and no command line holds a path.
61
+ const listed = pkg === 'eslint' && list !== undefined ? makeTempDir('starci-eslint-') : null;
62
+ let childArgs = [entry, ...args];
63
+ if (listed !== null && list !== undefined) {
64
+ const job = path.join(listed, 'job.json');
65
+ fs.writeFileSync(job, JSON.stringify({ files: list, fix: args.includes('--fix') }));
66
+ childArgs = [LIST_RUNNER, job];
67
+ }
68
+ const count = list?.length ?? files;
69
+ let run;
70
+ try {
71
+ run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app check ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), ...childArgs], cwd, maxBuffer: 64 * 1024 * 1024, ...(count === undefined || pkg !== 'eslint' ? {} : { files: count }), ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
72
+ }
73
+ finally {
74
+ if (listed !== null)
75
+ fs.rmSync(listed, { recursive: true, force: true });
76
+ }
135
77
  // ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
136
78
  // without a spawn error or signal are measurable, and exit 1 must carry findings.
137
79
  let results;
@@ -151,11 +93,6 @@ const eslintFindings = (results, appRoot) => results.flatMap((result) => (result
151
93
  engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: message.severity === 1 ? 'warning' : 'error',
152
94
  path: posix(path.relative(appRoot, result.filePath)), line: message.line ?? null, column: message.column ?? null, message: message.message,
153
95
  })));
154
- const stylelintFindings = (results, appRoot) => results.flatMap((result) => (result.warnings ?? []).map((warning) => ({
155
- engine: 'stylelint', rule: warning.rule || 'stylelint-error', code: null, severity: 'error',
156
- path: posix(path.relative(appRoot, result.source)), line: warning.line ?? null, column: warning.column ?? null, message: warning.text,
157
- })));
158
- const byLocation = (a, b) => `${a.path ?? ''}:${String(a.line ?? 0).padStart(7, '0')}:${a.rule}`.localeCompare(`${b.path ?? ''}:${String(b.line ?? 0).padStart(7, '0')}:${b.rule}`);
159
96
  /** The files of `changed` (app-relative) below `side`/, relative to the side folder. */
160
97
  const onSide = (changed, side) => changed.filter((file) => file.startsWith(`${side}/`)).map((file) => file.slice(side.length + 1));
161
98
  const fileKey = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
@@ -276,7 +213,7 @@ export async function installedLintConfig({ repoRoot, cwd, side }) {
276
213
  return [...config, ...familyConfig];
277
214
  }
278
215
  /** Read both installed canons' source selectors, then enumerate that scope; unavailable selectors cannot prove coverage. */
279
- async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
216
+ async function sourceFiles({ repoRoot, cwd, side }) {
280
217
  const config = await installedLintConfig({ repoRoot, cwd, side });
281
218
  const scope = canonScope(config);
282
219
  const files = [];
@@ -294,11 +231,11 @@ async function sourceFiles({ repoRoot, cwd, side, scope: folderScope }) {
294
231
  files.push(file);
295
232
  }
296
233
  };
297
- visit(folderScope ?? '');
234
+ visit('');
298
235
  return files.sort(byCodeUnit);
299
236
  }
300
237
  /** Every requested source needs an explicit, well-formed result; suppressed findings and foreign paths cannot witness a completed lint. */
301
- function checkedEslintResults({ results, expected, cwd, scope, changed }) {
238
+ function checkedEslintResults({ results, expected, cwd, changed }) {
302
239
  const required = new Set(expected.map((file) => fileKey(path.join(cwd, file))));
303
240
  const seen = new Set(), valid = [], errors = [];
304
241
  for (const result of results) {
@@ -309,7 +246,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
309
246
  continue;
310
247
  }
311
248
  const key = fileKey(result.filePath);
312
- if (!inside(cwd, result.filePath) || (scope && !inside(path.join(cwd, scope), result.filePath)) || (changed && !required.has(key))) {
249
+ if (!inside(cwd, result.filePath) || (changed && !required.has(key))) {
313
250
  errors.push(`eslint produced a result outside the requested scope: ${result.filePath}`);
314
251
  continue;
315
252
  }
@@ -325,8 +262,6 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
325
262
  errors.push(`eslint produced no result for requested source files: ${missing.join(', ')}`);
326
263
  return { results: valid, errors };
327
264
  }
328
- const ESLINT_ARGV_BUDGET = 24_000;
329
- const ESLINT_CONCURRENCY = 3;
330
265
  /** A launch that threw instead of returning a record is a tool error of the run (exit 2), not a crash of the CLI. */
331
266
  const spawnFailure = (error) => {
332
267
  if (isNodeExecutionRefusal(error))
@@ -334,13 +269,9 @@ const spawnFailure = (error) => {
334
269
  return { error: `could not start eslint: ${String(error?.message ?? error)}` };
335
270
  };
336
271
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
337
- async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run = runLinter }) {
272
+ async function launchEslint({ app, repoRoot, opts, changed, existing, run = runLinter, scopes = {} }) {
338
273
  const launches = [];
339
274
  for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
340
- if (workspace !== null && side !== STYLE_SIDE) {
341
- launches.push({ side, skipped: `outside --workspace ${workspace}` });
342
- continue;
343
- }
344
275
  const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
345
276
  if (changed !== null && !sources.length) {
346
277
  launches.push({ side, skipped: 'no changed source file' });
@@ -348,28 +279,30 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
348
279
  }
349
280
  const cwd = path.join(repoRoot, side);
350
281
  let expected;
282
+ // a caller that enumerated the side's scope already (the cache) hands it over: the canon config is loaded and the folders walked once per run
283
+ const known = scopes[side];
351
284
  try {
352
- expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe });
285
+ expected = changed ? sources : known !== undefined ? [...known] : await sourceFiles({ repoRoot, cwd, side });
353
286
  }
354
287
  catch (error) {
355
288
  launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` });
356
289
  continue;
357
290
  }
358
291
  const head = ['--format', 'json', ...(opts.fix ? ['--fix'] : [])];
359
- const launch = (files) => Promise.resolve().then(() => run({ cwd, pkg: 'eslint', bin: 'eslint', args: [...head, ...files], bound: repoRoot }));
360
- // A diff of hundreds of files exceeds the Windows command line: the sources go in chunks and the reports are merged.
361
- const chunks = changed ? chunkByChars(sources, ESLINT_ARGV_BUDGET) : [[onFe ?? '.']];
362
- launches.push({ side, cwd, expected, run: chunks.length === 1 ? launch(chunks[0]).catch(spawnFailure) : mapBounded(chunks, launch, ESLINT_CONCURRENCY).then(mergeRuns) });
292
+ // The files of a diff go to ONE child through a file (a diff of hundreds of long paths outgrows the Windows command line, and each extra
293
+ // child would build the whole TypeScript program again); the whole side is a single directory argument.
294
+ const launch = Promise.resolve().then(() => run(changed ? { cwd, pkg: 'eslint', bin: 'eslint', args: head, list: sources, bound: repoRoot } : { cwd, pkg: 'eslint', bin: 'eslint', args: [...head, '.'], files: expected.length, bound: repoRoot }));
295
+ launches.push({ side, cwd, expected, run: launch.catch(spawnFailure) });
363
296
  }
364
297
  return launches;
365
298
  }
366
- /** Execute each side's installed ESLint config once; check and lint share scope, coverage and tool-failure judgment. */
367
- export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], workspace = null, onFe = null, run }) {
299
+ /** Execute each side's installed ESLint config once, whole or over a named list; the cached and the cacheless run share scope, coverage and tool-failure judgment. */
300
+ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], run, scopes }) {
368
301
  const findings = [], errors = [], raw = [];
369
302
  const engine = { sides: {} };
370
303
  if (!app.sides)
371
304
  throw new Error('ESLint runs at the app root, the folder of hfs.json');
372
- const launches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run });
305
+ const launches = await launchEslint({ app, repoRoot, opts, changed, existing, run, ...(scopes === undefined ? {} : { scopes }) });
373
306
  const settled = await Promise.allSettled(launches.map(launch => launch.run ?? null));
374
307
  const rejected = settled.filter((run) => run.status === 'rejected');
375
308
  const failed = rejected.find(run => isHostHeld(run.reason)) ?? rejected[0];
@@ -391,7 +324,7 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
391
324
  if (linted.error)
392
325
  errors.push(linted.error);
393
326
  if (linted.results) {
394
- const checked = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
327
+ const checked = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, changed: changed !== null });
395
328
  errors.push(...checked.errors);
396
329
  linted.results = checked.results;
397
330
  const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
@@ -483,22 +416,18 @@ export function gitBaseToken(repoRoot) {
483
416
  * validation of a full run (eslintRepository), the rest take their records. The answer has the shape of eslintRepository's, plus the stylelint
484
417
  * results, so the lint report is built the same way from either.
485
418
  */
486
- export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run, cache }) {
419
+ export async function cachedLinters({ repoRoot, app, side: only, run, session }) {
487
420
  const eslint = { findings: [], errors: [], raw: [], engine: { sides: {} } };
488
421
  const style = { results: [], errors: [], ran: false };
489
422
  const sides = [];
490
- const decision = cache.session.decide('lint');
491
- const full = opts.full === true ? '--full' : decision.mode === 'full' ? decision.reason : null;
423
+ const decision = session.decide('lint');
424
+ const full = decision.mode === 'full' ? decision.reason : null;
492
425
  const gitToken = gitBaseToken(repoRoot);
493
- for (const side of SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate)) {
494
- if (workspace !== null && side !== STYLE_SIDE) {
495
- eslint.engine.sides[side] = { files: 0, skipped: `outside --workspace ${workspace}` };
496
- continue;
497
- }
426
+ for (const side of SIDES.filter((candidate) => only === undefined || only === candidate)) {
498
427
  const cwd = path.join(repoRoot, side);
499
428
  let scope, canons;
500
429
  try {
501
- scope = await sourceFiles({ repoRoot, cwd, side, scope: null });
430
+ scope = await sourceFiles({ repoRoot, cwd, side });
502
431
  canons = await canonDeclarations({ repoRoot, cwd, side });
503
432
  }
504
433
  catch (error) {
@@ -508,16 +437,15 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
508
437
  }
509
438
  const styled = side === STYLE_SIDE;
510
439
  const outcome = await lintSide({
511
- session: cache.session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
512
- report: onFe === null ? null : (file) => file.startsWith(`${onFe}/`),
513
- canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: workspace === null,
440
+ session, side, sideRoot: cwd, scope, stylesheets: styled ? stylesheetsOf(cwd) : [],
441
+ canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: true,
514
442
  eslint: async (files) => {
515
443
  const listed = files === null ? null : files.map((file) => `${side}/${file}`);
516
- const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], workspace: listed === null ? workspace : null, onFe: listed === null ? onFe : null, run });
444
+ const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope } });
517
445
  return { results: sub.raw, errors: sub.errors };
518
446
  },
519
447
  stylelint: styled ? async (files) => {
520
- const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json'] });
448
+ const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [STYLE_GLOB]), '--formatter', 'json'] });
521
449
  const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
522
450
  for (const result of linted.results ?? [])
523
451
  for (const warning of result.warnings ?? [])
@@ -536,164 +464,6 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
536
464
  style.ran = true;
537
465
  }
538
466
  }
539
- cache.session.report(lintFamilyReport(sides));
467
+ session.report(lintFamilyReport(sides));
540
468
  return { eslint, stylelint: style, sides };
541
469
  }
542
- /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
543
- function launchStylelint({ repoRoot, opts, changed, existing, onFe, run }) {
544
- if (opts.side === 'be')
545
- return null;
546
- const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
547
- if (changed !== null && !styles.length)
548
- return null;
549
- return (run ?? runLinter)({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
550
- }
551
- /**
552
- * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
553
- * injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
554
- */
555
- export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles = () => [], runLinter: linter, cache }) {
556
- const changed = opts.changed === null ? null : new Set(opts.changed);
557
- const errors = [];
558
- let workspace = null;
559
- let validSide = true;
560
- try {
561
- validateSide(opts);
562
- }
563
- catch (error) {
564
- errors.push(String(error?.message ?? error));
565
- validSide = false;
566
- }
567
- if (opts.workspace !== undefined) {
568
- try {
569
- workspace = workspaceOf(opts.workspace);
570
- }
571
- catch (error) {
572
- errors.push(String(error?.message ?? error));
573
- }
574
- }
575
- /** Whether an app-relative path lies inside the scoped workspace. */
576
- const inWorkspace = (file) => posix(file).startsWith(`${workspace}/`);
577
- const existing = changed ? [...changed].filter((file) => fs.existsSync(path.join(repoRoot, file))) : [];
578
- const findings = [];
579
- const raw = { eslint: [], stylelint: [] };
580
- const engines = {};
581
- let app = null;
582
- try {
583
- app = readRepoDeclaration(loadSlotManifest(), repoRoot);
584
- }
585
- catch (error) {
586
- errors.push(`starci app lint runs at the app root: ${String(error?.message ?? error)}`);
587
- }
588
- if (app && !app.sides) {
589
- errors.push(`${repoRoot} is the ${app.side} side of an app; starci app lint runs at the app root, the folder of hfs.json`);
590
- app = null;
591
- }
592
- if (!validSide || (opts.workspace !== undefined && workspace === null))
593
- app = null;
594
- if (workspace !== null && !fs.existsSync(path.join(repoRoot, workspace, 'package.json'))) {
595
- errors.push(`--workspace ${workspace} holds no package.json; it is not a workspace of this app`);
596
- app = null;
597
- }
598
- /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
599
- const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
600
- // The linters and the app check are independent: every child starts first, the app check runs while they work, and the results are read in a fixed order.
601
- // The cache serves a whole-side lint only: --changed judges named files and --fix rewrites them, so neither reads nor writes a record.
602
- const cached = cache !== undefined && changed === null && !opts.fix ? cache : null;
603
- const cachedRun = app && cached ? cachedLinters({ app, repoRoot, opts, workspace, onFe, run: linter, cache: cached }) : null;
604
- const eslintRun = app ? (cachedRun ? cachedRun.then((answer) => answer.eslint) : eslintRepository({ app, repoRoot, opts, changed: changed ? [...changed] : null, existing, workspace, onFe, run: linter })) : null;
605
- const styleLaunch = app && !cachedRun ? launchStylelint({ repoRoot, opts, changed, existing, onFe, run: linter }) : null;
606
- const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value, error: undefined }), (error) => ({ value: undefined, error })) : null;
607
- // A transport refusal does not abandon a sibling child or let its rejection escape unobserved.
608
- const completed = await Promise.allSettled([eslintRun, styleLaunch, checkRun]);
609
- const refused = completed.filter((run) => run.status === 'rejected');
610
- const failure = refused.find(run => isHostHeld(run.reason)) ?? refused[0];
611
- if (failure !== undefined)
612
- throw failure.reason;
613
- // 1. ESLint, once per side, from the side folder with that side's config.
614
- const eslint = eslintRun ? await eslintRun : { engine: { sides: {} }, errors: [], raw: [], findings: [] };
615
- engines.eslint = eslint.engine;
616
- errors.push(...eslint.errors);
617
- raw.eslint.push(...eslint.raw);
618
- findings.push(...eslint.findings);
619
- // 3. stylelint over the fe side's stylesheets.
620
- if (app) {
621
- if (cachedRun) {
622
- const answer = (await cachedRun).stylelint;
623
- errors.push(...answer.errors);
624
- if (answer.ran)
625
- raw.stylelint = answer.results;
626
- findings.push(...stylelintFindings(answer.results, repoRoot));
627
- }
628
- else if (styleLaunch) {
629
- const linted = await styleLaunch;
630
- if (linted.error)
631
- errors.push(linted.error);
632
- if (linted.results) {
633
- const appRelative = appRelativeMessages(STYLE_SIDE, path.join(repoRoot, STYLE_SIDE));
634
- for (const result of linted.results)
635
- for (const warning of result.warnings ?? [])
636
- warning.text = appRelative(warning.text);
637
- raw.stylelint = linted.results;
638
- findings.push(...stylelintFindings(linted.results, repoRoot));
639
- }
640
- }
641
- engines.stylelint = { side: STYLE_SIDE, files: raw.stylelint.length };
642
- }
643
- // 2. The app check: root and sides.
644
- let checked = { findings: [] };
645
- if (checkRun) {
646
- const settled = await checkRun;
647
- if (isNodeExecutionRefusal(settled.error))
648
- throw settled.error;
649
- if (settled.error)
650
- errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
651
- else
652
- checked = settled.value;
653
- }
654
- const repoErrors = checked.findings.filter((finding) => finding.level === 'error' && findingOnSide(finding, opts.side));
655
- // A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
656
- const kept = workspace !== null
657
- ? repoErrors.filter((finding) => finding.path && inWorkspace(finding.path))
658
- : changed === null ? repoErrors : repoErrors.filter((finding) => !finding.path || changed.has(posix(finding.path)));
659
- for (const finding of kept) {
660
- findings.push({ engine: 'hfs', rule: finding.code, code: finding.code, severity: 'error', path: finding.path ? posix(finding.path) : null, line: finding.line ?? null, column: finding.column ?? null, message: finding.message, titleVi: finding.titleVi, nextStepVi: finding.nextStepVi });
661
- }
662
- engines.hfs = { findings: kept.length };
663
- findings.sort(byLocation);
664
- let properties = '';
665
- try {
666
- properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8');
667
- }
668
- catch { /* no properties: every finding keeps its own path */ }
669
- const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
670
- const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
671
- const sonar = mergeReports([
672
- sonarReport(kept, { sourceRoots, tracked }),
673
- linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
674
- linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
675
- ]);
676
- // Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes. A warning is a
677
- // rule of the canon's counting cohort (eslint-canon-fe COUNTING): reported and counted per rule, never part of the verdict.
678
- const failing = findings.filter((finding) => finding.severity !== 'warning');
679
- const counting = {};
680
- for (const finding of findings)
681
- if (finding.severity === 'warning')
682
- counting[finding.rule] = (counting[finding.rule] ?? 0) + 1;
683
- const gate = gateVerdict(failing);
684
- const report = { schema: LINT_SCHEMA, ok: gate.pass && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: failing.length, warning: findings.length - failing.length }, counting, engines, errors, findings, gate };
685
- return { report, sonar, exit: errors.length ? 2 : gate.pass ? 0 : 1 };
686
- }
687
- export function printLintText(report, out) {
688
- for (const f of report.findings)
689
- out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.severity === 'warning' ? 'warn ' : ''}${f.engine}/${f.rule} ${f.message}\n`);
690
- for (const e of report.errors)
691
- out(`ERROR ${e}\n`);
692
- const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine && f.severity !== 'warning').length}`).join(', ');
693
- out(`\nstarci app lint: ${report.counts.error} finding${report.counts.error === 1 ? '' : 's'} (${per})${report.errors.length ? `, ${report.errors.length} tool error${report.errors.length === 1 ? '' : 's'}` : ''}\n`);
694
- const counting = Object.entries((report.counting ?? {})).sort(([a], [b]) => a.localeCompare(b));
695
- if (counting.length)
696
- out(`counting (warn, not in the verdict): ${counting.map(([rule, count]) => `${rule} ${count}`).join(', ')}\n`);
697
- if (report.gate?.line)
698
- out(`${report.gate.line}\n`);
699
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.0.7",
3
+ "version": "5.0.8",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import type { VerbContext } from './index.mjs';
2
2
  /**
3
- * Runs `starci app lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
3
+ * Runs `starci app check --gate lint --side <side> --out <file>` for every side the app has, then writes reports/lint.sonar.json.
4
4
  * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
5
5
  * A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
6
6
  */