@starci/hfs 2.0.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -16
  3. package/bin/hfs.mjs +55 -48
  4. package/lint/run.mjs +130 -0
  5. package/package.json +2 -1
  6. package/report/sonar.mjs +14 -29
  7. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  8. package/runtime/knowledge/sonar-gate.yaml +8 -7
  9. package/runtime/modules/kernel/failure-codes.yaml +8 -20
  10. package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
  11. package/runtime/scripts/checks/architecture/hfs.mjs +1 -1
  12. package/runtime/scripts/checks/architecture/index.mjs +18 -13
  13. package/runtime/scripts/checks/architecture/owners.mjs +12 -6
  14. package/runtime/scripts/checks/architecture/surface.mjs +91 -0
  15. package/runtime/scripts/checks/architecture/typescript.mjs +82 -9
  16. package/runtime/scripts/checks/architecture.mjs +2 -1
  17. package/runtime/scripts/checks/common.mjs +8 -1
  18. package/runtime/scripts/lib/hfs-check.mjs +11 -75
  19. package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
  20. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
  21. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
  22. package/templates/be/ci-workflows/github/workflows/ci.yml +1 -9
  23. package/templates/be/hooks/husky/pre-push +2 -3
  24. package/templates/be/package-scripts/package.json +3 -6
  25. package/templates/be/quality-config/sonar-project.properties +1 -1
  26. package/templates/fe/ci-workflows/github/workflows/ci.yml +1 -15
  27. package/templates/fe/hooks/husky/pre-push +2 -3
  28. package/templates/fe/package-scripts/package.json +3 -7
  29. package/templates/fe/quality-config/sonar-project.properties +1 -1
  30. package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
@@ -59,8 +59,9 @@ import { pipelineFindings } from './hfs-rules/pipeline.mjs';
59
59
  import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
60
60
  import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
61
61
  import { readJson } from './hfs-rules/read.mjs';
62
- import { secretFindings, slotOwnsSecrets } from './hfs-rules/secrets.mjs';
63
- import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
62
+ import { secretFindings } from './hfs-rules/secrets.mjs';
63
+ import { pathFindings } from './hfs-path-findings.mjs';
64
+ import { onLintSurface } from '../checks/architecture/surface.mjs';
64
65
  import { stacksFindings } from './hfs-rules/stacks.mjs';
65
66
  import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
66
67
  import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
@@ -148,53 +149,6 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
148
149
  return findings;
149
150
  }
150
151
 
151
- const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
152
- const SOURCE_ROOT = /^(?:src|apps)\//;
153
- const FREE_NAMES = new Set(['index.ts', 'main.ts']);
154
- const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
155
-
156
- /**
157
- * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
158
- * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
159
- * as api.composition.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
160
- * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
161
- */
162
- function sourceFormFindings({ files, resolver }) {
163
- const { suffixes, bannedSuffixes } = resolver.ruleParams();
164
- // 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
165
- // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
166
- const boundSuffixes = new Map();
167
- for (const slot of resolver.slots()) {
168
- const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
169
- if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
170
- }
171
- const findings = [];
172
- for (const file of files) {
173
- if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
174
- const c = resolver.classifyPath(file);
175
- if (c.status !== 'owned' || c.tracking === 'ignored') continue;
176
- const base = path.posix.basename(file);
177
- if (FREE_NAMES.has(base)) continue;
178
- // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
179
- const slot = resolver.slot(c.slot);
180
- if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
181
- // 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.
182
- const admitted = allowsFile(resolver, file);
183
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
184
- if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
185
- const parts = base.slice(0, -'.ts'.length).split('.');
186
- const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
187
- if (banned) {
188
- 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(', ')})` });
189
- } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
190
- 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` });
191
- } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
192
- 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` });
193
- }
194
- }
195
- return findings;
196
- }
197
-
198
152
  /** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
199
153
  function instancesOf(resolver, files) {
200
154
  const found = new Map();
@@ -267,7 +221,7 @@ function treeFindings({ repoRoot, resolver }) {
267
221
  * counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
268
222
  * an exception.
269
223
  */
270
- export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }) }) {
224
+ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
271
225
  const why = readWhy(root);
272
226
  let repo;
273
227
  try {
@@ -285,25 +239,7 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
285
239
  const scoped = only ? new Set(only) : null;
286
240
  const inScope = (file) => !scoped || scoped.has(file);
287
241
 
288
- for (const file of tracked) {
289
- if (!inScope(file)) continue;
290
- if (repo.profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
291
- const c = resolver.classifyPath(file);
292
- if (c.status === 'no-slot') {
293
- 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'}` : ''}` });
294
- } else if (c.status === 'ambiguous') {
295
- 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(', ')}` });
296
- } else if (c.status === 'not-enabled') {
297
- 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` });
298
- } else if (c.status === 'forbidden') {
299
- const slot = resolver.slot(c.slot);
300
- if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
301
- const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
302
- 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}` : ''}` });
303
- } else if (c.tracking === 'ignored') {
304
- 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` });
305
- }
306
- }
242
+ findings.push(...pathFindings({ files: tracked.filter(inScope), resolver, profile: repo.profile }));
307
243
 
308
244
  const required = resolver.requiredPaths();
309
245
  const missing = new Set();
@@ -329,7 +265,6 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
329
265
  // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-repository, cheap, no tool run.
330
266
  const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
331
267
  const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
332
- if (repo.profile === 'be') findings.push(...sourceFormFindings({ files: tracked.filter(inScope), resolver }));
333
268
  findings.push(
334
269
  ...secrets,
335
270
  ...depFindings({ repoRoot, files: tracked }),
@@ -337,7 +272,7 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
337
272
  ...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
338
273
  ...repoLocalCheckFindings({ repoRoot, files: tracked }),
339
274
  ...lintSuppressionFindings({ repoRoot, files: tracked }),
340
- ...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...specPlacementFindings({ files: tracked, resolver }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
275
+ ...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
341
276
  ...extraFindings,
342
277
  );
343
278
 
@@ -351,7 +286,8 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
351
286
 
352
287
  if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
353
288
 
354
- const finished = withWhy(findings, why);
289
+ // `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
290
+ const finished = withWhy(surface === 'check' ? findings.filter((finding) => !(finding.origin === 'repo' && onLintSurface(repoRoot, finding))) : findings, why);
355
291
  const counts = summarize(finished);
356
292
  return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps: repo.apps, tracked: tracked.length, findings: finished, counts };
357
293
  }
@@ -399,8 +335,8 @@ function machineFindings(report) {
399
335
  */
400
336
  export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
401
337
  const changed = fast ? changedSince(repoRoot, base) : null;
402
- const baseSha = changed ? changed.base : (base ? (mergeBaseOf(repoRoot, base) ?? refuse('HFS_REPO_UNREADABLE', `--base ${base} has no merge-base with HEAD`, { repoRoot, base })) : undefined);
403
- const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, ...(changed ? { only: changed.files, tree: false } : {}) });
338
+ const baseSha = changed?.base;
339
+ const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, surface: 'check', ...(changed ? { only: changed.files, tree: false } : {}) });
404
340
  if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
405
341
 
406
342
  let paths;
@@ -411,7 +347,7 @@ export function checkRepository({ repoRoot, root = skillRoot, fast = false, base
411
347
  }
412
348
  let report;
413
349
  try {
414
- report = machine({ repositoryRoot: repoRoot, base: baseSha, ...(changed ? { paths, fast: true } : {}) });
350
+ report = machine({ repositoryRoot: repoRoot, base: baseSha, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
415
351
  } catch (error) {
416
352
  report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
417
353
  }
@@ -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
- }