@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.
- package/CHANGELOG.md +8 -0
- package/README.md +12 -16
- package/bin/hfs.mjs +51 -47
- package/lint/run.mjs +130 -0
- package/package.json +2 -1
- package/report/sonar.mjs +14 -29
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/sonar-gate.yaml +8 -7
- package/runtime/modules/kernel/failure-codes.yaml +8 -20
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/checks/architecture/hfs.mjs +1 -1
- package/runtime/scripts/checks/architecture/index.mjs +18 -13
- package/runtime/scripts/checks/architecture/owners.mjs +12 -6
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +82 -9
- package/runtime/scripts/lib/hfs-check.mjs +11 -75
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
- package/templates/be/ci-workflows/github/workflows/ci.yml +1 -9
- package/templates/be/hooks/husky/pre-push +2 -3
- package/templates/be/package-scripts/package.json +3 -6
- package/templates/be/quality-config/sonar-project.properties +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +1 -15
- package/templates/fe/hooks/husky/pre-push +2 -3
- package/templates/fe/package-scripts/package.json +3 -7
- package/templates/fe/quality-config/sonar-project.properties +1 -1
- package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
|
@@ -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
|
|
2
|
-
// .github/workflows/ci.yml a `run:` step that runs `hfs
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
|
37
|
-
|
|
38
|
-
if (!
|
|
39
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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,
|
|
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
|
|
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": "
|
|
8
|
-
"lint:
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
9
|
-
"lint:
|
|
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/
|
|
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
|
-
}
|