@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
@@ -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
  }
@@ -7,6 +7,7 @@
7
7
  import path from 'node:path';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {checkArchitecture} from './architecture/index.mjs';
10
+ import { isMain } from './common.mjs';
10
11
 
11
12
  export {checkArchitecture};
12
13
 
@@ -38,4 +39,4 @@ export function architectureMain(argv, {check = checkArchitecture, write = (text
38
39
  return report?.ok === true ? 0 : 1;
39
40
  }
40
41
 
41
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = architectureMain(process.argv.slice(2));
42
+ if (isMain(import.meta.url)) process.exitCode = architectureMain(process.argv.slice(2));
@@ -9,7 +9,14 @@ export function isInside(root, target) {
9
9
  }
10
10
 
11
11
  /** True when this module is the process entry point (`node file.mjs`, not an import). */
12
- export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && path.resolve(argv[1]) === fileURLToPath(metaUrl);
12
+ /** The real path of a file, or its resolved path when it cannot be read (a missing file is never the entry module). */
13
+ const realPathOf = (file) => { try { return fs.realpathSync.native(path.resolve(file)); } catch { return path.resolve(file); } };
14
+ /**
15
+ * True when the module at `metaUrl` is the process entry point. Both sides are compared as REAL paths: Node gives a module
16
+ * its symlink-resolved URL, while argv[1] keeps the path it was started with, so an entry reached through a node_modules
17
+ * junction or symlink (a lane worktree, an npx shim) would otherwise look like an import and silently do nothing.
18
+ */
19
+ export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && realPathOf(argv[1]) === realPathOf(fileURLToPath(metaUrl));
13
20
 
14
21
  /**
15
22
  * Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full