@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.
- package/CHANGELOG.md +12 -0
- package/README.md +12 -16
- package/bin/hfs.mjs +55 -48
- package/lint/run.mjs +130 -0
- package/package.json +2 -1
- package/report/sonar.mjs +14 -29
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/sonar-gate.yaml +8 -7
- package/runtime/modules/kernel/failure-codes.yaml +8 -20
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/checks/architecture/hfs.mjs +1 -1
- package/runtime/scripts/checks/architecture/index.mjs +18 -13
- package/runtime/scripts/checks/architecture/owners.mjs +12 -6
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +82 -9
- package/runtime/scripts/checks/architecture.mjs +2 -1
- package/runtime/scripts/checks/common.mjs +8 -1
- package/runtime/scripts/lib/hfs-check.mjs +11 -75
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
- package/templates/be/ci-workflows/github/workflows/ci.yml +1 -9
- package/templates/be/hooks/husky/pre-push +2 -3
- package/templates/be/package-scripts/package.json +3 -6
- package/templates/be/quality-config/sonar-project.properties +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +1 -15
- package/templates/fe/hooks/husky/pre-push +2 -3
- package/templates/fe/package-scripts/package.json +3 -7
- package/templates/fe/quality-config/sonar-project.properties +1 -1
- package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1486
|
-
- "Ứng dụng thiếu main.ts
|
|
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
|
|
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
|
|
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, ...
|
|
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,
|
|
146
|
-
* backend composition and data machine and frontend repository machine. A
|
|
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, ...
|
|
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,
|
|
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),
|
|
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
|
-
|
|
253
|
-
const
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|