@starci/hfs 2.0.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ // hfs-path-findings.mjs - the per-path judgements of the slot manifest: what a tracked path is. One function, two surfaces: `hfs check`
2
+ // (checkRepo) runs it over the tracked tree and keeps the findings that have no TypeScript file to sit on; the lint canon's project graph
3
+ // (scripts/lib/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
4
+ // HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
5
+ // BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
6
+ import path from 'node:path';
7
+ import { allowsFile } from './hfs-allows.mjs';
8
+ import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
9
+ import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
10
+ import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
11
+
12
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const SOURCE_ROOT = /^(?:src|apps)\//;
14
+ const FREE_NAMES = new Set(['index.ts', 'main.ts']);
15
+ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
16
+
17
+ /**
18
+ * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
19
+ * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
20
+ * as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
21
+ * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
22
+ */
23
+ function sourceFormFindings({ files, resolver }) {
24
+ const { suffixes, bannedSuffixes } = resolver.ruleParams();
25
+ // A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
26
+ // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
27
+ const boundSuffixes = new Map();
28
+ for (const slot of resolver.slots()) {
29
+ const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
30
+ if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
31
+ }
32
+ const findings = [];
33
+ for (const file of files) {
34
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
35
+ const c = resolver.classifyPath(file);
36
+ if (c.status !== 'owned' || c.tracking === 'ignored') continue;
37
+ const base = path.posix.basename(file);
38
+ if (FREE_NAMES.has(base)) continue;
39
+ // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
40
+ const slot = resolver.slot(c.slot);
41
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
42
+ // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
43
+ const admitted = allowsFile(resolver, file);
44
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
45
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
46
+ const parts = base.slice(0, -'.ts'.length).split('.');
47
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
48
+ if (banned) {
49
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
50
+ } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
51
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
52
+ } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
53
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
54
+ }
55
+ }
56
+ return findings;
57
+ }
58
+
59
+ /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
60
+ export function pathFindings({ files, resolver, profile }) {
61
+ const findings = [];
62
+ for (const file of files) {
63
+ if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
64
+ const c = resolver.classifyPath(file);
65
+ if (c.status === 'no-slot') {
66
+ findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
67
+ } else if (c.status === 'ambiguous') {
68
+ findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
69
+ } else if (c.status === 'not-enabled') {
70
+ findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
71
+ } else if (c.status === 'forbidden') {
72
+ const slot = resolver.slot(c.slot);
73
+ if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
74
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
75
+ findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
76
+ } else if (c.tracking === 'ignored') {
77
+ findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
78
+ }
79
+ }
80
+
81
+ if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
82
+ // The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
83
+ return findings.map((finding) => ({ ...finding, origin: 'repo' }));
84
+ }
@@ -1,8 +1,9 @@
1
- // pipeline.mjs - HFS_CI_MISSING_CANON (R13): CI runs the pinned `hfs check`; pre-push runs typecheck and lint.
2
- // .github/workflows/ci.yml a `run:` step that runs `hfs check` as a whole (not `--fast`): `npx hfs check`, `npx @starci/hfs[@<version>]
3
- // check`, or `npm run <script>` of the root package.json whose command is `hfs check`; a version named in
4
- // the step is the @starci/hfs pin of canon-pins.yaml (an installed one is pinned by the pin check, R15)
5
- // .husky/pre-push `npm run typecheck` and `npm run lint:check`, each on a line of its own
1
+ // pipeline.mjs - HFS_CI_MISSING_CANON (R13): CI runs the pinned `hfs lint`; pre-push runs typecheck and lint.
2
+ // .github/workflows/ci.yml a `run:` step that runs `hfs lint` (the one lint entry: eslint, the repository check, stylelint): `npx hfs lint`,
3
+ // `npx @starci/hfs[@<version>] lint`, or `npm run <script>` of the root package.json whose command runs `hfs lint`
4
+ // (`npm run lint`, arguments after `--` allowed); a version named in the step is the @starci/hfs pin of
5
+ // canon-pins.yaml (an installed one is pinned by the pin check, R15)
6
+ // .husky/pre-push `npm run typecheck` and `npm run lint`, each on a line of its own
6
7
  // A missing file is the slot manifest's finding (HFS_SLOT_REQUIRED_MISSING); a file that is present and lacks the step is this rule's.
7
8
  // The redirect of `core.hooksPath` away from husky is the architecture machine's (HFS_HOOKS_PATH_REDIRECTED).
8
9
  import { found, readJson, readText } from './read.mjs';
@@ -12,9 +13,8 @@ export const CI_FILE = '.github/workflows/ci.yml';
12
13
  export const PRE_PUSH_FILE = '.husky/pre-push';
13
14
  export const hfsPackage = '@starci/hfs';
14
15
  const RUN_LINE = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/;
15
- const HFS_CHECK = /^(?:npx\s+(?:--no-install\s+|-y\s+)?)?(?:@starci\/hfs|hfs)(?:@(\S+))?\s+check(\s.*)?$/;
16
+ const HFS_LINT = /^(?:npx\s+(?:--no-install\s+|-y\s+)?)?(?:@starci\/hfs|hfs)(?:@(\S+))?\s+lint(\s.*)?$/;
16
17
  const NPM_RUN = /^npm run ([\w:.-]+)(?:\s+--\s+(.*))?$/;
17
- const FAST = /(^|\s)--fast(\s|$)/;
18
18
 
19
19
  const commandsOf = (text) => text.split(/\r?\n/).map((line) => RUN_LINE.exec(line)?.[1]).filter(Boolean).map((command) => command.replace(/^["']|["']$/g, ''));
20
20
 
@@ -33,17 +33,16 @@ export function pipelineFindings({ repoRoot, files, pins }) {
33
33
  const ci = files.includes(CI_FILE) ? readText(repoRoot, CI_FILE) : null;
34
34
  if (ci !== null) {
35
35
  const scripts = readJson(repoRoot, 'package.json')?.scripts ?? {};
36
- const checks = commandsOf(ci).map((command) => HFS_CHECK.exec(expanded(command, scripts))).filter(Boolean);
37
- const whole = checks.filter((match) => !FAST.test(match[2] ?? ''));
38
- if (!whole.length) findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`hfs check\` (\`npx hfs check\`, or an npm script that is exactly that); CI must run the whole pinned hfs check, not --fast`, { step: 'hfs check' }));
39
- else if (pinned && !whole.some((match) => match[1] === undefined || match[1] === pinned)) {
40
- findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs hfs check at ${whole[0][1]}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'hfs check', pinned }));
36
+ const lints = commandsOf(ci).flatMap((command) => expanded(command, scripts).split(/\s*&&\s*/)).map((command) => HFS_LINT.exec(command)).filter(Boolean);
37
+ if (!lints.length) findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`hfs lint\` (\`npm run lint\`, or \`npx hfs lint\`); CI runs the one lint entry, which includes the repository check`, { step: 'hfs lint' }));
38
+ else if (pinned && !lints.some((match) => match[1] === undefined || match[1] === pinned)) {
39
+ findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs hfs lint at ${lints[0][1]}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'hfs lint', pinned }));
41
40
  }
42
41
  }
43
42
  const prePush = files.includes(PRE_PUSH_FILE) ? readText(repoRoot, PRE_PUSH_FILE) : null;
44
43
  if (prePush !== null) {
45
44
  const lines = prePush.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
46
- for (const step of ['typecheck', 'lint:check']) {
45
+ for (const step of ['typecheck', 'lint']) {
47
46
  if (!runsStep(lines, step)) findings.push(found(CI_MISSING_CANON, PRE_PUSH_FILE, `${PRE_PUSH_FILE} does not run \`npm run ${step}\`; pre-push runs typecheck and lint`, { step }));
48
47
  }
49
48
  }
@@ -2,8 +2,6 @@
2
2
  // - a unit spec is `<name>.service.spec.ts` beside its service, in a slot whose `tests` is `unit-beside` (the slot and the lint rule
3
3
  // `unit-test-colocated` judge the name);
4
4
  // - integration, e2e and contract specs are `src/tests/{integration,e2e,contract}/...`, the slots whose `tests` is `e2e`;
5
- // - an app's own folder (`apps/<app>/src/`, the slots that name an `appKind`) holds the app's composition spec, which the architecture
6
- // machine's app-composition check judges.
7
5
  // Every other tracked `*.spec.*`, `*.test.*` or `*-spec.*` file is a finding, `scripts/` and `tools/` included: an operational script
8
6
  // carries no spec, and a spec that guards one moves into a layer or goes. A file no slot owns (`tools/x.spec.ts`) is judged too, so the
9
7
  // finding names the rule rather than only the missing slot. Which folder is a layer is read from the slot manifest, never spelled here.
@@ -23,7 +21,7 @@ export function specPlacementFindings({ files, resolver }) {
23
21
  if (!isSpecFile(file) || file.includes('node_modules/')) continue;
24
22
  const classified = resolver.classifyPath(file);
25
23
  const slot = classified.status === 'owned' ? resolver.slot(classified.slot) : null;
26
- if (slot && (TEST_SLOT_TESTS.has(slot.tests) || slot.appKind !== undefined)) continue;
24
+ if (slot && TEST_SLOT_TESTS.has(slot.tests)) continue;
27
25
  const where = slot ? `slot ${slot.id}` : 'no slot';
28
26
  findings.push(found(SPEC_PLACEMENT, file, `${file} is a spec outside the test layers (${where}); a unit spec is <name>.service.spec.ts beside its service and an integration, e2e or contract spec sits under src/tests/{integration,e2e,contract}; an operational script or a tool carries no spec`));
29
27
  }
@@ -21,16 +21,8 @@ jobs:
21
21
  node-version: {{nodeMajor}}
22
22
  cache: npm
23
23
  - run: npm ci
24
- - name: hfs check
25
- run: npm run hfs:report
26
24
  - name: lint
27
- run: npm run lint:check
28
- - name: lint report
29
- if: ${{ !cancelled() }}
30
- run: npm run lint:report
31
- - name: eslint sonar import
32
- if: ${{ !cancelled() }}
33
- run: npx hfs report eslint reports/eslint.json reports/eslint.sonar.json
25
+ run: npm run lint -- --sonar reports/lint.sonar.json
34
26
  - name: format
35
27
  run: npm run format:check
36
28
  - name: typecheck
@@ -1,7 +1,6 @@
1
1
  {{header}}
2
- # Push gate: types, lint, format, the architecture check on the owners this push touches, unit specs affected since main. Never integration, e2e or contract.
2
+ # Push gate: types, lint, format, unit specs affected since main. Never integration, e2e or contract.
3
3
  npm run typecheck
4
- npm run lint:check
4
+ npm run lint
5
5
  npm run format:check
6
- npm run hfs:check -- --fast
7
6
  npm run test:affected -- --changedSince=origin/main
@@ -4,9 +4,8 @@
4
4
  {{appScripts}}
5
5
  "typecheck": "tsc -p tsconfig.json",
6
6
  "typecheck:tests": "tsc -p src/tests/tsconfig.json",
7
- "lint": "eslint --fix .",
8
- "lint:check": "eslint .",
9
- "lint:report": "eslint . --format json --output-file reports/eslint.json",
7
+ "lint": "hfs lint",
8
+ "lint:fix": "hfs lint --fix",
10
9
  "format": "prettier --write .",
11
10
  "format:check": "prettier --check .",
12
11
  "test": "jest --selectProjects unit --coverage",
@@ -15,8 +14,6 @@
15
14
  "test:e2e": "npm run typecheck:tests && jest --selectProjects e2e",
16
15
  "test:contract": "npm run typecheck:tests && jest --selectProjects contract",
17
16
  "test:stack": "starci-test-stack",
18
- "contract:emit": "hfs emit-contracts",
19
- "hfs:check": "hfs check",
20
- "hfs:report": "hfs check --sonar reports/hfs.sonar.json"
17
+ "contract:emit": "hfs emit-contracts"
21
18
  }
22
19
  }
@@ -5,5 +5,5 @@ sonar.sources=apps,src
5
5
  sonar.tests=apps,src
6
6
  sonar.exclusions={{sonarExclusions}}
7
7
  sonar.test.inclusions=**/*.spec.ts
8
- sonar.externalIssuesReportPaths=reports/hfs.sonar.json,reports/eslint.sonar.json
8
+ sonar.externalIssuesReportPaths=reports/lint.sonar.json
9
9
  sonar.nodejs.maxspace=8192
@@ -21,22 +21,8 @@ jobs:
21
21
  node-version: {{nodeMajor}}
22
22
  cache: npm
23
23
  - run: npm ci
24
- - name: hfs check
25
- run: npm run hfs:report
26
24
  - name: lint
27
- run: npm run lint:check
28
- - name: eslint report
29
- if: ${{ !cancelled() }}
30
- run: npm run lint:report
31
- - name: eslint sonar import
32
- if: ${{ !cancelled() }}
33
- run: npx hfs report eslint reports/eslint.json reports/eslint.sonar.json
34
- - name: stylelint report
35
- if: ${{ !cancelled() }}
36
- run: npm run lint:report:css
37
- - name: stylelint sonar import
38
- if: ${{ !cancelled() }}
39
- run: npx hfs report stylelint reports/stylelint.json reports/stylelint.sonar.json
25
+ run: npm run lint -- --sonar reports/lint.sonar.json
40
26
  - name: format
41
27
  run: npm run format:check
42
28
  - name: typecheck
@@ -1,6 +1,5 @@
1
1
  {{header}}
2
- # Push gate: types, lint (eslint over the repository, stylelint over the CSS), format, the architecture check on the owners this push touches.
2
+ # Push gate: types, lint (eslint over the repository, stylelint over the CSS), format; one `hfs lint` runs eslint, the repository check and stylelint.
3
3
  npm run typecheck
4
- npm run lint:check
4
+ npm run lint
5
5
  npm run format:check
6
- npm run hfs:check -- --fast
@@ -5,13 +5,9 @@
5
5
  "codegen": "npm run codegen --workspaces --if-present",
6
6
  "build": "npm run build --workspaces --if-present",
7
7
  "typecheck": "npm run typecheck --workspaces --if-present",
8
- "lint": "npm run codegen --silent && eslint . --fix --max-warnings=0 && stylelint \"{{styleGlob}}\" --fix",
9
- "lint:check": "npm run codegen --silent && eslint . --max-warnings=0 && stylelint \"{{styleGlob}}\"",
10
- "lint:report": "eslint . --format json --output-file reports/eslint.json",
11
- "lint:report:css": "stylelint \"{{styleGlob}}\" --formatter json --output-file reports/stylelint.json",
8
+ "lint": "npm run codegen --silent && hfs lint --stylelint \"{{styleGlob}}\"",
9
+ "lint:fix": "npm run codegen --silent && hfs lint --fix --stylelint \"{{styleGlob}}\"",
12
10
  "format": "prettier --write .",
13
- "format:check": "prettier --check .",
14
- "hfs:check": "hfs check",
15
- "hfs:report": "hfs check --sonar reports/hfs.sonar.json"
11
+ "format:check": "prettier --check ."
16
12
  }
17
13
  }
@@ -4,5 +4,5 @@ sonar.sourceEncoding=UTF-8
4
4
  sonar.sources={{sonarRoots}}
5
5
  sonar.exclusions=**/.next/**,**/node_modules/**,**/src/messages/**
6
6
  sonar.typescript.tsconfigPaths={{tsconfigPaths}}
7
- sonar.externalIssuesReportPaths=reports/hfs.sonar.json,reports/eslint.sonar.json,reports/stylelint.sonar.json
7
+ sonar.externalIssuesReportPaths=reports/lint.sonar.json
8
8
  sonar.nodejs.maxspace=8192
@@ -1,73 +0,0 @@
1
- import fs from 'node:fs';
2
- import { gitOutput } from '../../lib/git.mjs';
3
-
4
- /**
5
- * HFS check 6, file size growth (knowledge/hfs/slots.yaml ruleParams.<profile>.fileLines {soft, hardGrowth}):
6
- * a production source file over `soft` lines may not grow against the merge-base, and a file that is new at the base
7
- * must stay within `soft`. A file at or under `soft` passes, even when it grows, as long as it does not cross `soft`
8
- * (crossing is growth of an over-budget file: the base had fewer lines than soft). Files are compared to the base file
9
- * of the same path only, so a moved file is judged as new. The per-slot budgets (component 300, hook 200 ...) belong to
10
- * the lint layer and are not judged here.
11
- */
12
- export const SIZE_GROWTH_RULE_IDS = ['HFS_SIZE_GROWTH'];
13
-
14
- const MAX_BUFFER = 64 * 1024 * 1024;
15
-
16
- function git(root, args) {
17
- try {
18
- return gitOutput(args, { cwd: root, maxBuffer: MAX_BUFFER });
19
- } catch {
20
- return null;
21
- }
22
- }
23
-
24
- /** The commit the growth is measured against: the given commit-ish, else merge-base with upstream, origin/main, origin/master, else HEAD's first parent. */
25
- function resolveBase(root, requested) {
26
- const commit = (rev) => { const out = git(root, ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`]); return out ? out.trim() : null; };
27
- if (requested) return commit(requested);
28
- if (!commit('HEAD')) return null;
29
- for (const other of ['@{upstream}', 'origin/main', 'origin/master']) {
30
- if (!commit(other)) continue;
31
- const merged = git(root, ['merge-base', 'HEAD', other]);
32
- if (merged?.trim()) return merged.trim();
33
- }
34
- return commit('HEAD^');
35
- }
36
-
37
- export function countLines(text) {
38
- if (!text) return 0;
39
- const count = text.split('\n').length;
40
- return text.endsWith('\n') ? count - 1 : count;
41
- }
42
-
43
- export function checkSizeGrowth({ config, graph, base } = {}) {
44
- const root = config.root;
45
- const sha = resolveBase(root, base);
46
- if (!sha) return { violations: [], coverage: { status: 'unavailable', reason: 'no merge-base' } };
47
- const { soft, hardGrowth } = graph.resolver.ruleParams().fileLines;
48
- const violations = [];
49
- let files = 0;
50
- let overSoft = 0;
51
- let grown = 0;
52
- let newOverSoft = 0;
53
- for (const rel of [...graph.files.keys()].sort()) {
54
- let text;
55
- try { text = fs.readFileSync(graph.files.get(rel).abs, 'utf8'); } catch { continue; }
56
- files += 1;
57
- const lines = countLines(text);
58
- if (lines <= soft) continue;
59
- overSoft += 1;
60
- const baseText = git(root, ['cat-file', 'blob', `${sha}:${rel}`]);
61
- const baseLines = baseText === null ? null : countLines(baseText);
62
- if (baseLines === null) {
63
- newOverSoft += 1;
64
- if (hardGrowth) violations.push({ ruleId: 'HFS_SIZE_GROWTH', path: rel, line: 1, lines, baseLines: null, soft,
65
- message: `${rel} is a new file of ${lines} lines, over the ${soft}-line soft budget; split it into smaller files before adding it.` });
66
- } else if (lines > baseLines) {
67
- grown += 1;
68
- if (hardGrowth) violations.push({ ruleId: 'HFS_SIZE_GROWTH', path: rel, line: 1, lines, baseLines, soft,
69
- message: `${rel} has ${lines} lines, over the ${soft}-line soft budget, and grew from ${baseLines} lines at the base; a file over budget may only shrink, so split the new code into another file.` });
70
- }
71
- }
72
- return { violations, coverage: { status: 'checked', base: sha, files, overSoft, grown, newOverSoft } };
73
- }