@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.
@@ -245,10 +245,9 @@ BE_APP_BUSINESS_ROLE:
245
245
  BE_APP_COMPOSITION_ONLY:
246
246
  title: "Composition root has stray source"
247
247
  title_vi: "Gốc ghép nối có tệp lạc chỗ"
248
- meaning_vi: "Mã trong ứng dụng gốc không phải tệp mà slot ứng dụng cho phép (main.ts, app.module.ts, <app>.options.ts)."
248
+ meaning_vi: "Mã trong ứng dụng gốc không phải tệp mà slot ứng dụng cho phép (main.ts, app.module.ts, <app>.options.ts; operations.ts ở ứng dụng API)."
249
249
  causes_vi:
250
250
  - "Thêm tệp không đúng tên vào thư mục ứng dụng gốc"
251
- - "Composition spec của app tự khai AppModule, import AppModule từ tệp khác app.module, hoặc không gọi AppModule.register( — module thật không bao giờ được khởi chạy"
252
251
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
253
252
  owner: op-retry
254
253
  kind: check-finding
@@ -516,6 +515,7 @@ BE_SPEC_QUALITY:
516
515
  - "Spec chỉ có toHaveBeenCalled*, không khẳng định kết quả hay trạng thái"
517
516
  - "`it.skip`, `describe.skip`, `xit`, `it.todo`, `.skipIf`/`.runIf` hoặc `(cond ? describe : describe.skip)` trong spec; hãy viết test thật hoặc xóa nó, còn việc bỏ qua khi thiếu sandbox nằm trong contract helper của world (`sandbox.describe(...)`)"
518
517
  - "e2e gọi CommandBus, QueryBus hay handler trực tiếp; dùng sleep thay cho waitFor; tự gọi Test.createTestingModule; import SDK của nhà cung cấp mô hình; không đọc lại dòng đã lưu qua EntityManager"
518
+ - "Spec đơn vị của service khẳng định `expect.any(String)`, `expect.any(Number)` hoặc `expect.any(Date)` cho giá trị service tự sinh; service nhận bộ sinh id và đồng hồ qua token, spec cấp `fakeIds()` và `new FakeClock(...)` từ @starci/jest-preset rồi khẳng định đúng giá trị"
519
519
  - "Spec đơn vị của service có providers khác với phụ thuộc constructor: thừa nhà cung cấp, thiếu nhà cung cấp, service không đứng đầu, token không suy ra được hoặc nhà cung cấp không phải lớp hay { provide, useValue }"
520
520
  nextStep_vi: "Chuyển luật kiến trúc sang lint hoặc bộ kiểm tra; khẳng định kết quả hoặc trạng thái; e2e vào bằng transport thật, chờ bằng waitFor và dựng thế giới ở src/tests/e2e/setup. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra."
521
521
  owner: op-retry
@@ -981,11 +981,11 @@ HFS_CANON_PIN_DRIFT:
981
981
  kind: check-finding
982
982
 
983
983
  HFS_CI_MISSING_CANON:
984
- title: "CI runs the pinned `@starci/hfs check`; pre-push runs typecheck and lint"
984
+ title: "CI runs the pinned `hfs lint`; pre-push runs typecheck and lint"
985
985
  title_vi: "CI thiếu bước canon"
986
986
  meaning_vi: "Workflow/husky thiếu bước `<step>`. Cấu trúc chỉ được kiểm khi agent chạy — CI phải tự kiểm."
987
987
  causes_vi:
988
- - "Vi phạm luật R13: CI phải chạy `npx @starci/hfs check` (bản pin); pre-push phải có `typecheck` + `lint:check`."
988
+ - "Vi phạm luật R13: CI phải chạy `hfs lint` (`npm run lint` hoặc `npx @starci/hfs lint`, bản pin); pre-push phải có `typecheck` + `lint`."
989
989
  nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra (nợ hàng loạt có codemod của HFS); không cần ai can thiệp thêm."
990
990
  owner: op-retry
991
991
  kind: check-finding
@@ -1283,7 +1283,7 @@ HFS_README_DESCRIPTION_INVALID:
1283
1283
  HFS_README_DEVELOPMENT_INCOMPLETE:
1284
1284
  title: "README Development section incomplete"
1285
1285
  title_vi: "Mục Development trong README chưa đủ lệnh"
1286
- meaning_vi: "Mục Development chưa nêu đủ các lệnh cài đặt, typecheck, lint:check, build và test (các script do hfs sync quản lý; test chạy bằng npm test)."
1286
+ meaning_vi: "Mục Development chưa nêu đủ các lệnh cài đặt, typecheck, lint, build và test (các script do hfs sync quản lý; test chạy bằng npm test)."
1287
1287
  causes_vi:
1288
1288
  - "Quên một hoặc vài lệnh"
1289
1289
  - "Viết bằng lệnh của trình quản lý khác"
@@ -1433,18 +1433,6 @@ HFS_RULE_OFF_WITHOUT_REPLACEMENT:
1433
1433
  owner: op-retry
1434
1434
  kind: check-finding
1435
1435
 
1436
- HFS_SIZE_GROWTH:
1437
- title: "Source file over the size budget grew"
1438
- title_vi: "Tệp mã nguồn vượt hạn mức dòng vẫn tiếp tục dài thêm"
1439
- meaning_vi: "Một tệp mã nguồn đã dài hơn hạn mức mềm của ruleParams.fileLines nhưng lại dài thêm so với phiên bản gốc của nhánh, hoặc một tệp mới tạo đã vượt hạn mức ngay từ đầu. Tệp quá dài khó đọc, khó sửa và là nơi lỗi dễ ẩn; quy tắc này cho phép tệp cũ đang quá dài được giữ nguyên hoặc thu nhỏ dần, nhưng không cho phép nó phình thêm."
1440
- causes_vi:
1441
- - "Op thêm mã mới vào một tệp vốn đã quá hạn mức thay vì tách phần mới sang tệp khác"
1442
- - "Một tệp đang dưới hạn mức được thêm mã cho đến khi vượt hạn mức"
1443
- - "Một tệp mới tạo dài hơn hạn mức, hoặc một tệp bị di chuyển sang đường dẫn mới nên bị coi là tệp mới"
1444
- nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để tách phần mã mới (hoặc bớt phần cũ) sang tệp riêng đúng ô của nó, sao cho tệp không dài hơn phiên bản gốc và tệp mới nằm trong hạn mức."
1445
- owner: op-retry
1446
- kind: check-finding
1447
-
1448
1436
  HFS_SIZE_SOFT_BACKLOG:
1449
1437
  title: "Source file above the soft size"
1450
1438
  title_vi: "Tệp mã vượt cỡ mềm (chỉ báo cáo)"
@@ -1478,12 +1466,12 @@ HFS_SLOT_NOT_ENABLED:
1478
1466
  kind: check-finding
1479
1467
 
1480
1468
  HFS_SLOT_REQUIRED_MISSING:
1481
- title: "A slot is missing a required file (feature `index.ts`, app composition spec, FE `global-error.tsx`)"
1469
+ title: "A slot is missing a required file (feature `index.ts`, app `main.ts`, FE `global-error.tsx`)"
1482
1470
  title_vi: "Ô thiếu tệp bắt buộc"
1483
1471
  meaning_vi: "Một ô bắt buộc (hoặc một thực thể của ô, như một ứng dụng hay một tính năng) đòi có tệp hoặc thư mục này nhưng nó không nằm trong những gì Git theo dõi."
1484
1472
  causes_vi:
1485
- - "Vi phạm luật R02: slot bắt buộc thiếu tệp `requires` (vd feature thiếu `index.ts`, app thiếu composition spec, FE thiếu `global-error.tsx`)."
1486
- - "Ứng dụng thiếu main.ts, app.module.ts hoặc spec ghép ứng dụng"
1473
+ - "Vi phạm luật R02: slot bắt buộc thiếu tệp `requires` (vd feature thiếu `index.ts`, app thiếu `main.ts`, FE thiếu `global-error.tsx`)."
1474
+ - "Ứng dụng thiếu main.ts hoặc app.module.ts"
1487
1475
  - "Kho thiếu tệp gốc bắt buộc như README.md, hfs.json, .starciwork, .starcistacks"
1488
1476
  nextStep_vi: "Tạo tệp hoặc thư mục theo mẫu của ô rồi commit; tệp chưa git add vẫn tính là thiếu. Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
1489
1477
  owner: op-retry
@@ -5,7 +5,7 @@ import { relativePath } from './typescript.mjs';
5
5
  * The file and owner graph the HFS architecture checks share. Every production source file the TypeScript context
6
6
  * loaded becomes a node classified by the slot manifest (config.hfs is the resolver of scripts/lib/hfs-slots.mjs);
7
7
  * every import, re-export and type-only import between two of them becomes an edge. Nothing here judges: the checks
8
- * in tiers.mjs, reachability.mjs, dead-exports.mjs, required-files.mjs, size-growth.mjs and clones.mjs read it.
8
+ * in tiers.mjs, reachability.mjs, dead-exports.mjs, required-files.mjs and clones.mjs read it.
9
9
  *
10
10
  * graph.profile 'be' | 'fe'
11
11
  * graph.resolver the slot resolver (config.hfs)
@@ -189,7 +189,7 @@ function privateHost(hostname) {
189
189
 
190
190
  // The scripts the README Development section shows; each is required only when the managed package-scripts template of the
191
191
  // profile (packages/hfs/templates/<profile>/package-scripts/package.json, the one source of the managed script names) has it.
192
- const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint:check', 'build', 'test'];
192
+ const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint', 'build', 'test'];
193
193
  // The templates sit beside the runtime in a checkout (packages/hfs/templates) and one level above the bundled runtime of @starci/hfs.
194
194
  const TEMPLATE_ROOTS = [path.resolve(import.meta.dirname, '..', '..', '..', 'packages', 'hfs', 'templates'), path.resolve(import.meta.dirname, '..', '..', '..', '..', 'templates')];
195
195
  const managedScriptCache = new Map();
@@ -15,7 +15,6 @@ import { checkTiers, TIER_RULE_IDS } from './tiers.mjs';
15
15
  import { checkReachability, REACHABILITY_RULE_IDS } from './reachability.mjs';
16
16
  import { checkDeadExports, DEAD_EXPORT_RULE_IDS } from './dead-exports.mjs';
17
17
  import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS } from './required-files.mjs';
18
- import { checkSizeGrowth, SIZE_GROWTH_RULE_IDS } from './size-growth.mjs';
19
18
  import { checkClones, CLONE_RULE_IDS } from './clones.mjs';
20
19
  import { checkSymbols, SYMBOL_RULE_IDS } from './symbols.mjs';
21
20
  import { checkConnectionMap, CONNECTION_RULE_IDS } from './connection-map.mjs';
@@ -43,6 +42,7 @@ import { checkPackageShape, PACKAGE_SHAPE_RULE_IDS } from './package-shape.mjs';
43
42
  import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs';
44
43
  import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
45
44
  import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
45
+ import { LINT_CODES, onLintSurface } from './surface.mjs';
46
46
 
47
47
  export { REGISTRATION_RULE_IDS, SWR_DATA_RULE_IDS };
48
48
 
@@ -138,13 +138,12 @@ export const ERROR_RULE_IDS = Object.freeze([
138
138
  ]);
139
139
 
140
140
  const machineIds = () => [
141
- ...TIER_RULE_IDS, ...REACHABILITY_RULE_IDS, ...DEAD_EXPORT_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...SIZE_GROWTH_RULE_IDS, ...CLONE_RULE_IDS, ...SYMBOL_RULE_IDS,
141
+ ...TIER_RULE_IDS, ...REACHABILITY_RULE_IDS, ...DEAD_EXPORT_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...CLONE_RULE_IDS, ...SYMBOL_RULE_IDS,
142
142
  ...Object.values(BACKEND_MACHINE).flatMap(([, ids]) => ids), ...Object.values(FRONTEND_MACHINE).flatMap(([, ids]) => ids),
143
143
  ];
144
144
  /**
145
- * The rules only the HFS machine emits: the tier, reachability, dead-export, required-file, size, clone and symbol checks and the
146
- * backend composition and data machine and frontend repository machine. A rule id an ordinary check also emits (the composition
147
- * spec and the app-composition check both report BE_APP_COMPOSITION_ONLY) is not here. A spec about another rule judges its own
145
+ * The rules only the HFS machine emits: the tier, reachability, dead-export, required-file, clone and symbol checks and the
146
+ * backend composition and data machine and frontend repository machine. A spec about another rule judges its own
148
147
  * report without these (they have their own specs); every one of them is in ARCHITECTURE_RULE_IDS.
149
148
  */
150
149
  export const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
@@ -156,7 +155,7 @@ export const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
156
155
  /** Every code the machine can emit, derived from the rule id lists of its checks (`hfs check` ships exactly these why entries). */
157
156
  export const ARCHITECTURE_RULE_IDS = Object.freeze([...new Set([
158
157
  ...COMMON_RULE_IDS, ...BACKEND_RULE_IDS, ...FRONTEND_RULE_IDS, ...HFS_RULE_IDS, ...TIER_RULE_IDS, ...REACHABILITY_RULE_IDS,
159
- ...DEAD_EXPORT_RULE_IDS, ...DOC_LANGUAGE_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...SIZE_GROWTH_RULE_IDS, ...CLONE_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS,
158
+ ...DEAD_EXPORT_RULE_IDS, ...DOC_LANGUAGE_RULE_IDS, ...REQUIRED_FILE_RULE_IDS, ...CLONE_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS,
160
159
  ...REGISTRATION_RULE_IDS, ...SWR_DATA_RULE_IDS, ...SYMBOL_RULE_IDS, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID, PUBLIC_CONTRACT_RULE_ID,
161
160
  READONLY_BOUNDARY_RULE_ID, ...ERROR_RULE_IDS, ...CONFIG_UNREAD_RULE_IDS, ...Object.values(BACKEND_MACHINE).flatMap(([, ids]) => ids), ...Object.values(FRONTEND_MACHINE).flatMap(([, ids]) => ids),
162
161
  ])].sort());
@@ -169,7 +168,8 @@ function stable(items) {
169
168
  * Check a target repository. injectedTypeScript exists only for hermetic rule fixtures. `fast` leaves out the checks
170
169
  * that read the whole repository to answer (clones, dead exports, repository-wide symbols); the pre-push check of the changed owners uses it.
171
170
  */
172
- export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs } = {}) {
171
+ export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs, surface = 'all' } = {}) {
172
+ if (!['all', 'lint', 'check'].includes(surface)) throw new Error(`unknown surface ${surface}`);
173
173
  let config;
174
174
  try {
175
175
  config = loadArchitectureConfig(repositoryRoot, { hfs: openedHfs });
@@ -233,24 +233,30 @@ export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths =
233
233
  }
234
234
  // HFS machine: the slot-driven graph checks read the whole program, also when the caller asked for one path.
235
235
  const hfsContext = paths.length ? buildTypeScriptContext(config, injectedTypeScript) : context;
236
- const hfsChecks = { tiers: null, reachability: null, deadExports: null, requiredFiles: null, sizeGrowth: null, clones: null, symbols: null, docLanguage: null };
236
+ const hfsChecks = { tiers: null, reachability: null, deadExports: null, requiredFiles: null, clones: null, symbols: null, docLanguage: null };
237
237
  if (hfsContext.program) {
238
238
  const graph = buildHfsGraph(config, hfsContext);
239
239
  const input = { config, context: hfsContext, graph, base };
240
240
  const runs = { tiers: () => checkTiers(graph), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
241
- requiredFiles: () => checkRequiredFiles(input), sizeGrowth: () => checkSizeGrowth(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
241
+ requiredFiles: () => checkRequiredFiles(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
242
242
  if (graph.profile === 'be') for (const [name, [check]] of Object.entries(BACKEND_MACHINE)) runs[name] = () => check(input);
243
243
  if (graph.profile === 'fe') for (const [name, [check]] of Object.entries(FRONTEND_MACHINE)) runs[name] = () => check(input);
244
244
  if (fast) { delete runs.deadExports; delete runs.clones; delete runs.symbols; }
245
245
  for (const [name, run] of Object.entries(runs)) {
246
246
  const result = run();
247
- violations.push(...result.violations);
247
+ violations.push(...result.violations.map(violation => ({ ...violation, check: name })));
248
248
  hfsChecks[name] = result.coverage;
249
249
  }
250
250
  }
251
251
  const inScope = item => !paths.length || (item.path && paths.some(prefix => sameOrUnder(item.path, prefix.replace(/\/$/, ''))));
252
- const errors = stable(context.errors.filter(item => item.ruleId.startsWith('ARCH_TSCONFIG_') || !item.path || inScope(item)));
253
- const scopedViolations = stable(violations.filter(inScope));
252
+ // Two findings travel as context errors (package boundary edges); they are obligations of the lint surface like any other finding.
253
+ const asViolation = item => LINT_CODES.has(item.ruleId);
254
+ const allErrors = context.errors.filter(item => item.ruleId.startsWith('ARCH_TSCONFIG_') || !item.path || inScope(item));
255
+ const obligations = surface === 'all' ? violations : [...violations, ...context.errors.filter(asViolation).filter(inScope)];
256
+ // The lint surface is what an editor can show on a line of a TypeScript file; `hfs check` keeps every other finding of the machine.
257
+ const onSurface = item => surface === 'all' || onLintSurface(config.root, item) === (surface === 'lint');
258
+ const errors = stable(surface === 'all' ? allErrors : allErrors.filter(item => !asViolation(item)));
259
+ const scopedViolations = stable(obligations.filter(inScope).filter(onSurface));
254
260
  const sourceFiles = new Set(context.files.map(file => canonical(file.fileName)));
255
261
  const missingOwnerEntries = config.owners?.filter(owner => !sourceFiles.has(canonical(path.resolve(config.root, ...owner.entry.split('/'))))) ?? [];
256
262
  const coverage = {
@@ -282,7 +288,6 @@ export function checkArchitecture({ repositoryRoot, injectedTypeScript, paths =
282
288
  ...(hfsChecks.reachability?.status === 'checked' ? REACHABILITY_RULE_IDS : []),
283
289
  ...(hfsChecks.deadExports?.status === 'checked' ? DEAD_EXPORT_RULE_IDS : []),
284
290
  ...(hfsChecks.requiredFiles?.status === 'checked' ? REQUIRED_FILE_RULE_IDS : []),
285
- ...(hfsChecks.sizeGrowth?.status === 'checked' ? SIZE_GROWTH_RULE_IDS : []),
286
291
  ...(hfsChecks.clones?.status === 'checked' ? CLONE_RULE_IDS : []),
287
292
  ...(hfsChecks.symbols?.status === 'checked' ? SYMBOL_RULE_IDS : []),
288
293
  ...(hfsChecks.docLanguage?.status === 'checked' ? DOC_LANGUAGE_RULE_IDS : []),
@@ -1,14 +1,20 @@
1
1
  import path from 'node:path';
2
2
  import { canonical, isInside } from './config.mjs';
3
- import { relativePath, sourceLocation } from './typescript.mjs';
3
+ import { relativePath, sourceLocation, workspaceExportSources } from './typescript.mjs';
4
4
 
5
5
  function absolute(root, relative) {
6
6
  return canonical(path.resolve(root, ...relative.split('/')));
7
7
  }
8
8
 
9
- function ownerDeclarations(config) {
10
- return config.owners.map(owner => ({ ...owner, root: absolute(config.root, owner.root), entry: absolute(config.root, owner.entry) }))
11
- .sort((a, b) => b.root.length - a.root.length);
9
+ /** The owner's public entries: its declared entry, plus every source file its package.json `exports` maps when the owner is a workspace package. */
10
+ function ownerDeclarations(config, context) {
11
+ return config.owners.map(owner => {
12
+ const root = absolute(config.root, owner.root);
13
+ const entry = absolute(config.root, owner.entry);
14
+ const workspace = context.workspaces?.find(item => item.root === root);
15
+ const entries = new Set([entry, ...(workspace ? workspaceExportSources(context.ts, workspace) : [])]);
16
+ return { ...owner, root, entry, entries };
17
+ }).sort((a, b) => b.root.length - a.root.length);
12
18
  }
13
19
 
14
20
  function ownerOf(owners, file) {
@@ -24,7 +30,7 @@ function privateOwnerChain(context, owners, edge) {
24
30
  if (visited.has(current.file)) continue;
25
31
  visited.add(current.file);
26
32
  const owner = ownerOf(owners, current.file);
27
- if (owner && sourceOwner?.id !== owner.id) return current.file === owner.entry ? null : { owner, chain: current.chain };
33
+ if (owner && sourceOwner?.id !== owner.id) return owner.entries.has(current.file) ? null : { owner, chain: current.chain };
28
34
  for (const candidate of context.edges.get(current.file) ?? []) if (candidate.reexport) {
29
35
  queue.push({ file: candidate.to, chain: [...current.chain, candidate.to] });
30
36
  }
@@ -46,7 +52,7 @@ function arrangesSchema(config, from, to) {
46
52
  /** Enforce explicit same-source owner entries without constraining imports inside one owner. */
47
53
  export function checkOwners(config, context) {
48
54
  if (!config.owners?.length) return [];
49
- const owners = ownerDeclarations(config);
55
+ const owners = ownerDeclarations(config, context);
50
56
  const violations = [];
51
57
  const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
52
58
  for (const owner of owners) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The lint surface: which architecture-machine findings an ESLint rule judges instead of `hfs check`.
3
+ * Single source of truth. Each enforcer id is the rules.yaml enforcer; `codes` are the exact violation.ruleId strings its
4
+ * check file emits on source files. plugins: 'be' = back-end profile repos, 'fe' = front-end profile, graph checks run for both.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ const BE = ['be'];
10
+ const FE = ['fe'];
11
+ const BOTH = ['be', 'fe'];
12
+
13
+ // `via` names the machine check (the key of its run in index.mjs) when two enforcers share a code; `origin: 'repo'` marks a finding of the
14
+ // slot manifest's per-path judgement (hfs-path-findings.mjs) instead of the machine. A finding reaches exactly one rule.
15
+ const enforcer = (id, plugins, codes, description, extra = {}) => Object.freeze({ id, plugins: Object.freeze(plugins), codes: Object.freeze(codes), description, ...extra });
16
+
17
+ export const LINT_ENFORCERS = Object.freeze([
18
+ enforcer('duplicate-code', BOTH, ['HFS_DUPLICATE_CODE'], 'A block of production code never repeats elsewhere in the owner graph.'),
19
+ enforcer('duplicate-symbol', BOTH, ['HFS_DUPLICATE_SYMBOL'], 'A symbol name is declared once across the production source.'),
20
+ enforcer('alias-reexport', BOTH, ['HFS_ALIAS_REEXPORT'], 'An owner entry re-exports a symbol under its own name, never through a const alias.'),
21
+ // cross-app-duplicate emits only FE_CROSS_APP_DUPLICATE today; HFS_DUPLICATE_CODE is named in its comment only.
22
+ enforcer('cross-app-duplicate', FE, ['FE_CROSS_APP_DUPLICATE'], 'A file is never copied between apps; shared code lives in a package.'),
23
+ enforcer('dead-exports', BOTH, ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'], 'Every owner export and every production file is reached by a root.'),
24
+ enforcer('tier-direction', BOTH, ['BE_TIER_DIRECTION', 'FE_TIER_DIRECTION'], 'An import goes only to a tier the slot matrix allows.'),
25
+ enforcer('owner-cycle', BOTH, ['ARCH_OWNER_CYCLE'], 'Owners never import each other in a cycle.'),
26
+ enforcer('feature-imports-feature', BE, ['BE_FEATURE_IMPORTS_FEATURE'], 'A feature never imports another feature.'),
27
+ enforcer('app-isolation', FE, ['FE_APP_ISOLATION'], 'An app never imports another app.'),
28
+ enforcer('feature-layout', BE, ['BE_FEATURE_LAYOUT_INVALID'], 'A feature file sits in the folder its role names.'),
29
+ enforcer('application-never-imports-transport', BE, ['BE_APPLICATION_IMPORTS_TRANSPORT', 'BE_APPLICATION_TRANSPORT_FRAMEWORK'], 'Application code never imports transport code, directly or through a chain.'),
30
+ enforcer('owner-export-bypass', BOTH, ['ARCH_OWNER_EXPORT_BYPASS', 'ARCH_OWNER_EXPORT_STAR'], 'Another owner is imported only through its index entry, and an entry never uses export star.'),
31
+ enforcer('feature-not-composed', BE, ['BE_FEATURE_NOT_COMPOSED'], 'Every feature owner is composed into an app.'),
32
+ enforcer('owner-reachable', FE, ['FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'], 'Every owner is mounted by a route and every href resolves to a route.'),
33
+ enforcer('app-composition-only', BE, ['BE_APP_COMPOSITION_ONLY', 'BE_APP_BUSINESS_ROLE'], 'An app only composes modules and holds no business role.'),
34
+ enforcer('entrypoint-only-in-apps', BE, ['BE_ENTRYPOINT_ONLY_IN_APPS'], 'NestFactory and bootstrap live only in an app main file.'),
35
+ enforcer('schema-owner', BE, ['BE_SCHEMA_OWNER'], 'Each entity array is registered by one owner across all apps.'),
36
+ enforcer('error-code-unique', BE, ['BE_ERROR_HOME'], 'An error code is declared once in the repository.'),
37
+ enforcer('error-masked', BE, ['BE_ERROR_MASKED'], 'Every app wires the error filter that masks internal errors.'),
38
+ enforcer('default-deny-app-guard', BE, ['BE_DEFAULT_DENY'], 'Every app registers the default-deny guard before any other guard.'),
39
+ // BE_MODULE_SHAPE is emitted by both register-once and module-per-transport.
40
+ enforcer('register-once', BE, ['BE_MODULE_SHAPE'], 'A module is registered once and imported by one module.', { via: 'registerOnce' }),
41
+ enforcer('module-per-transport', BE, ['BE_MODULE_SHAPE'], 'An app imports transport modules only.', { via: 'modulePerTransport' }),
42
+ enforcer('module-registration', BE, ['BE_MODULE_HANDLER_REGISTRATION', 'BE_MODULE_PROVIDER_REREGISTRATION'], 'Every handler is registered by a module and no provider is registered twice.'),
43
+ enforcer('background-unowned', BE, ['BE_BACKGROUND_UNOWNED'], 'Every background job is reachable from a worker app.'),
44
+ enforcer('test-world-files', BE, ['BE_TEST_TOPOLOGY'], 'The test world files match the stack declaration.'),
45
+ enforcer('unit-spec-providers', BE, ['BE_SPEC_QUALITY'], 'A unit spec provides exactly the dependencies its service constructor takes.'),
46
+ enforcer('transport-owner', FE, ['FE_TRANSPORT_OWNER'], 'One client and one outcome type own the transport of the repository.'),
47
+ enforcer('swr-data-lifecycle', FE, ['FE_SWR_KEY_IDENTITY', 'FE_SWR_MUTATION_RESOURCE_IDENTITY'], 'SWR keys and mutations share one resource identity.'),
48
+ enforcer('route-files-thin', FE, ['FE_ROUTE_FILES_THIN'], 'A route file only mounts one feature owner.'),
49
+ enforcer('route-adapter', FE, ['FE_ROUTE_ONE_PAGE', 'FE_ROUTE_DRAWING_DECISION', 'FE_ROUTE_CLIENT_BOUNDARY', 'FE_ROUTE_CLIENT_HOOK', 'FE_ROUTE_DEFAULT_EXPORT'], 'A page route is a server adapter that mounts one pages-tier component.'),
50
+ enforcer('client-reaches-server', FE, ['FE_CLIENT_REACHES_SERVER'], 'Client code never reaches server-only code through any import chain.'),
51
+ enforcer('hooks-are-hooks', FE, ['FE_HOOKS_ARE_HOOKS'], 'A hooks folder holds hooks only, one shared file per domain.'),
52
+ enforcer('hook-location', FE, ['FE_CUSTOM_HOOK_LOCATION', 'FE_BLOCK_PRODUCT_HOOK_DEFINITION', 'FE_COMPONENT_DEEP_HOOK_IMPORT'], 'A custom hook is defined in the hooks folder and imported through its entry.'),
53
+ enforcer('connection-map', BE, ['BE_CONNECTION_DUPLICATE'], 'Each database connection is declared once and matches the connection map.'),
54
+ enforcer('injection-token-exported', BE, ['BE_RAW_INJECT'], 'An injection token is exported by its owner and never injected raw.'),
55
+ enforcer('sql-owner', BE, ['BE_SQL_TABLE_OWNER'], 'A SQL table is touched only by the owner of its entity.'),
56
+ enforcer('readonly-boundary', BE, ['BE_READONLY_BOUNDARY'], 'Messages and injected classes crossing a boundary are readonly.'),
57
+ enforcer('source-names', BE, ['BE_SOURCE_FORM'], 'Names and forms inside a source file follow its role.'),
58
+ enforcer('public-contract-form', BE, ['BE_PUBLIC_CONTRACT_FORM'], 'A public signature is typed by a named contract, never inline or untyped.'),
59
+ enforcer('frontend-source-layout', FE, ['FE_SOURCE_LAYOUT_INVALID'], 'A front-end source file sits in a tier folder.'),
60
+ enforcer('component-purity', FE, ['FE_COMPONENT_WORLD_OWNERSHIP', 'FE_WORLD_OWNER_RENDER_BOUNDARY', 'FE_CONNECTED_BLOCK_RENDER_PAIR', 'FE_PURE_REACHES_DATA', 'FE_PURE_WORLD_HOOK', 'FE_PURE_WORLD_IMPORT'], 'A pure component reaches no data or world state; its connected entry owns them.'),
61
+ enforcer('grammar-entry', FE, ['ARCH_GRAMMAR_CONTRACT_INVALID', 'ARCH_GRAMMAR_EXPORT_BYPASS'], 'Vendor grammar is reached only through its owner entry.'),
62
+ enforcer('i18n-keys', FE, ['FE_I18N_KEYS'], 'Translation keys read by source and keys held by catalogs agree both ways.'),
63
+ // The per-path judgements of the slot manifest (scripts/lib/hfs-path-findings.mjs), on a tracked TypeScript file.
64
+ enforcer('slot-undeclared', BOTH, ['HFS_SLOT_UNDECLARED', 'HFS_SLOT_AMBIGUOUS', 'HFS_SLOT_NOT_ENABLED'], 'A TypeScript file is owned by exactly one slot of the repository.', { origin: 'repo' }),
65
+ enforcer('source-suffix', BE, ['BE_SOURCE_FORM'], 'A source file name is index.ts, main.ts, a migration or <kebab-name>.<suffix>.ts with a suffix of the closed list.', { origin: 'repo' }),
66
+ enforcer('spec-placement', BE, ['BE_SPEC_PLACEMENT'], 'A spec file lives in one of the four test layers and nowhere else.', { origin: 'repo' }),
67
+ // The two below are emitted into context.errors today, not into violations.
68
+ enforcer('package-imports-app', BOTH, ['ARCH_PACKAGE_IMPORTS_APP'], 'A package never imports an app.'),
69
+ enforcer('package-export-bypass', BOTH, ['ARCH_PACKAGE_EXPORT_BYPASS'], 'A package is imported only through its declared exports.'),
70
+ ]);
71
+
72
+ export const LINT_CODES = new Set(LINT_ENFORCERS.flatMap(e => e.codes));
73
+
74
+ export const TS_SOURCE = /\.(?:[cm]?tsx?)$/;
75
+
76
+ /** True when a finding sits on an existing TypeScript file of the repository: the only findings an editor can show on a line. */
77
+ export function attachesToSource(repositoryRoot, violation) {
78
+ const file = violation?.path;
79
+ if (typeof file !== 'string' || !TS_SOURCE.test(file)) return false;
80
+ return fs.existsSync(path.join(repositoryRoot, ...file.split('/')));
81
+ }
82
+
83
+ /** The finding's code: a machine violation spells it `ruleId`, a repository finding `code`. */
84
+ export const codeOf = (finding) => finding?.ruleId ?? finding?.code;
85
+
86
+ /** Whether `finding` (with its origin and check tags) belongs to `enforcer`. */
87
+ export const belongsTo = (enforcer, finding) => enforcer.codes.includes(codeOf(finding))
88
+ && (finding.origin ?? 'machine') === (enforcer.origin ?? 'machine') && (enforcer.via === undefined || finding.check === enforcer.via);
89
+
90
+ /** True when some lint rule owns the finding and it sits on an existing TypeScript file: it is an ESLint report, not a `hfs check` finding. */
91
+ export const onLintSurface = (repositoryRoot, finding) => LINT_ENFORCERS.some(enforcer => belongsTo(enforcer, finding)) && attachesToSource(repositoryRoot, finding);
@@ -184,7 +184,7 @@ function workspaceMetadata(config) {
184
184
  const root = path.join(config.root, ...relative.split('/'));
185
185
  const pkg = readJson(path.join(root, 'package.json'));
186
186
  const routeRoots = config.frontend.routes.map(item => path.join(config.root, ...item.split('/')));
187
- return { root: canonical(root), relative, name: typeof pkg.name === 'string' ? pkg.name : null, exports: pkg.exports,
187
+ return { root: canonical(root), relative, name: typeof pkg.name === 'string' ? pkg.name : null, exports: pkg.exports, manifest: pkg, workspace: true,
188
188
  app: routeRoots.some(route => isInside(root, route))
189
189
  || backendAppRoots.some(appRoot => isInside(appRoot, root) || isInside(root, appRoot)) };
190
190
  };
@@ -212,18 +212,19 @@ function exportTargetStrings(value) {
212
212
  return [];
213
213
  }
214
214
 
215
- function packageExported(workspace, specifier, actualTarget) {
216
- if (!workspace.name || !sameOrUnder(specifier, workspace.name)) return false;
215
+ /** The package-relative export targets a request (`.` or `./sub`) of a workspace package declares, from exports, else types/main. */
216
+ function exportCandidates(workspace, specifier) {
217
+ if (!workspace.name || !sameOrUnder(specifier, workspace.name)) return [];
217
218
  const request = specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`;
218
219
  const declaration = workspace.exports;
219
220
  let candidates = [];
220
221
  if (typeof declaration === 'string' || Array.isArray(declaration)) {
221
- if (request !== '.') return false;
222
+ if (request !== '.') return [];
222
223
  candidates = exportTargetStrings(declaration);
223
224
  } else if (declaration && typeof declaration === 'object') {
224
225
  const keys = Object.keys(declaration);
225
226
  if (!keys.some(key => key.startsWith('.'))) {
226
- if (request !== '.') return false;
227
+ if (request !== '.') return [];
227
228
  candidates = exportTargetStrings(declaration);
228
229
  } else {
229
230
  for (const key of keys) {
@@ -232,10 +233,80 @@ function packageExported(workspace, specifier, actualTarget) {
232
233
  }
233
234
  }
234
235
  }
235
- return candidates.some(target => {
236
+ return candidates;
237
+ }
238
+
239
+ const SOURCE_EXTENSIONS = { '.d.ts': ['.ts', '.tsx'], '.d.mts': ['.mts'], '.d.cts': ['.cts'], '.js': ['.ts', '.tsx'], '.jsx': ['.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
240
+
241
+ /** rootDir and outDir of a workspace package's build tsconfig (tsconfig.build.json, else tsconfig.json), extends followed; null when it declares none. */
242
+ function workspaceBuildLayout(ts, workspace) {
243
+ if (workspace.layout !== undefined) return workspace.layout;
244
+ workspace.layout = null;
245
+ for (const name of ['tsconfig.build.json', 'tsconfig.json']) {
246
+ const file = path.join(workspace.root, name);
247
+ if (!fs.existsSync(file)) continue;
248
+ const read = ts.readConfigFile(file, ts.sys.readFile);
249
+ if (read.error) continue;
250
+ const { options } = ts.parseJsonConfigFileContent(read.config, ts.sys, workspace.root, undefined, file);
251
+ if (options.outDir) { workspace.layout = { rootDir: options.rootDir ? path.resolve(options.rootDir) : path.join(workspace.root, 'src'), outDir: path.resolve(options.outDir) }; break; }
252
+ }
253
+ return workspace.layout;
254
+ }
255
+
256
+ /** The source file a built export target (`./dist/sub/index.js`, `.d.ts`) stands for: outDir back to rootDir, else the dist-to-src layout; null when none exists. */
257
+ function sourceOfTarget(ts, workspace, target) {
258
+ const absolute = path.resolve(workspace.root, target);
259
+ const layout = workspaceBuildLayout(ts, workspace);
260
+ const extension = Object.keys(SOURCE_EXTENSIONS).filter(item => absolute.endsWith(item)).sort((a, b) => b.length - a.length)[0];
261
+ const sourceRoots = [];
262
+ if (layout && isInside(layout.outDir, absolute)) sourceRoots.push([layout.outDir, layout.rootDir]);
263
+ const first = slash(path.relative(workspace.root, absolute)).split('/')[0];
264
+ if (first) sourceRoots.push([path.join(workspace.root, first), path.join(workspace.root, 'src')]);
265
+ if (!extension) return null;
266
+ for (const [from, to] of sourceRoots) {
267
+ const stem = path.join(to, path.relative(from, absolute)).slice(0, -extension.length);
268
+ for (const candidate of SOURCE_EXTENSIONS[extension]) if (fs.existsSync(stem + candidate)) return canonical(stem + candidate);
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /** The source entry a workspace import resolves to without a build: its export targets mapped back to source, `src/index.ts` for the package root. */
274
+ function workspaceSourceEntry(ts, workspace, specifier) {
275
+ if (!workspace.workspace || !workspace.name || !sameOrUnder(specifier, workspace.name)) return null;
276
+ let candidates = exportCandidates(workspace, specifier);
277
+ if (specifier === workspace.name && !workspace.exports) candidates = [workspace.manifest?.types, workspace.manifest?.typings, workspace.manifest?.module, workspace.manifest?.main].filter(item => typeof item === 'string');
278
+ for (const target of candidates) {
279
+ if (!target.startsWith('./') || target.includes('..')) continue;
280
+ const source = sourceOfTarget(ts, workspace, target);
281
+ if (source) return source;
282
+ }
283
+ if (specifier === workspace.name) {
284
+ for (const name of ['index.ts', 'index.tsx']) if (fs.existsSync(path.join(workspace.root, 'src', name))) return canonical(path.join(workspace.root, 'src', name));
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /** Every source file the package.json `exports` of a workspace package maps (each non-wildcard subpath, dist back to source): its public entries. */
290
+ export function workspaceExportSources(ts, workspace) {
291
+ const declaration = workspace.exports;
292
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return [];
293
+ const sources = new Set();
294
+ for (const [key, value] of Object.entries(declaration)) {
295
+ if (!key.startsWith('.') || key.includes('*')) continue;
296
+ for (const target of exportTargetStrings(value)) {
297
+ if (!target.startsWith('./') || target.includes('..')) continue;
298
+ const source = sourceOfTarget(ts, workspace, target);
299
+ if (source) sources.add(source);
300
+ }
301
+ }
302
+ return [...sources];
303
+ }
304
+
305
+ function packageExported(ts, workspace, specifier, actualTarget) {
306
+ return exportCandidates(workspace, specifier).some(target => {
236
307
  if (!target.startsWith('./') || target.includes('..')) return false;
237
308
  const expected = canonical(path.resolve(workspace.root, target));
238
- return expected === canonical(actualTarget);
309
+ return expected === canonical(actualTarget) || sourceOfTarget(ts, workspace, target) === canonical(actualTarget);
239
310
  });
240
311
  }
241
312
 
@@ -385,7 +456,9 @@ function typeScriptContext(config, loaded, paths) {
385
456
  message: `${item.kind} must use a string-literal module name so architecture coverage can resolve its dependency.`,
386
457
  });
387
458
  for (const reference of references.found) {
388
- const resolvedName = resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host);
459
+ // A workspace package is read at its source, never at its build: the same import resolves whether or not dist exists.
460
+ const workspaceTarget = workspaces.filter(item => item.root !== canonical(config.root)).map(item => workspaceSourceEntry(ts, item, reference.specifier)).find(Boolean);
461
+ const resolvedName = workspaceTarget ?? resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host);
389
462
  if (!resolvedName) {
390
463
  const codeLike = !ASSET_EXTENSION.test(reference.specifier);
391
464
  const workspaceImport = [...workspaceNames].some(name => sameOrUnder(reference.specifier, name));
@@ -443,7 +516,7 @@ function typeScriptContext(config, loaded, paths) {
443
516
  if (!owningWorkspace.app && targetWorkspace.app) {
444
517
  errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_IMPORTS_APP', 'A reusable workspace package cannot depend on an application workspace.'));
445
518
  }
446
- if (!packageExported(targetWorkspace, reference.specifier, actualTarget)) {
519
+ if (!packageExported(ts, targetWorkspace, reference.specifier, actualTarget)) {
447
520
  errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_EXPORT_BYPASS', 'Cross-package imports must use the target package name and a declared package export.'));
448
521
  }
449
522
  }
@@ -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
  }