@starci/hfs 1.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 (74) hide show
  1. package/README.md +38 -0
  2. package/bin/hfs.mjs +100 -0
  3. package/package.json +28 -0
  4. package/runtime/engine/runtime-root.mjs +32 -0
  5. package/runtime/engine/yaml.mjs +161 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +212 -0
  7. package/runtime/knowledge/hfs/slots.yaml +842 -0
  8. package/runtime/modules/kernel/failure-codes.yaml +169 -0
  9. package/runtime/scripts/lib/glob.mjs +23 -0
  10. package/runtime/scripts/lib/hfs-check.mjs +305 -0
  11. package/runtime/scripts/lib/hfs-slots.mjs +675 -0
  12. package/runtime/scripts/lib/path-key.mjs +15 -0
  13. package/sync/cli.mjs +15 -0
  14. package/sync/hygiene.mjs +92 -0
  15. package/sync/index.mjs +224 -0
  16. package/sync/skeleton.mjs +54 -0
  17. package/sync/sonar-key.mjs +45 -0
  18. package/templates/be/e2e.yml +21 -0
  19. package/templates/be/gitignore +2 -0
  20. package/templates/be/pre-commit +8 -0
  21. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +31 -0
  22. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +7 -0
  23. package/templates/be/skeleton/apps/__app__/src/app.module.ts +23 -0
  24. package/templates/be/skeleton/apps/__app__/src/main.ts +20 -0
  25. package/templates/be/skeleton/src/features/system-health/index.ts +1 -0
  26. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +6 -0
  27. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +13 -0
  28. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +18 -0
  29. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +10 -0
  30. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +36 -0
  31. package/templates/be/skeleton/src/modules/platform/config/env-source.ts +40 -0
  32. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +21 -0
  33. package/templates/be/skeleton/src/modules/platform/config/index.ts +5 -0
  34. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +19 -0
  35. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +15 -0
  36. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +8 -0
  37. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +15 -0
  38. package/templates/be/skeleton/src/modules/platform/errors/domain-error.ts +11 -0
  39. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +38 -0
  40. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +24 -0
  41. package/templates/be/skeleton/src/modules/platform/errors/index.ts +2 -0
  42. package/templates/be/skeleton/src/modules/platform/logging/index.ts +5 -0
  43. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +33 -0
  44. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +35 -0
  45. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +9 -0
  46. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +19 -0
  47. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +11 -0
  48. package/templates/be/sonar-project.properties +10 -0
  49. package/templates/be/starciwork.gitignore +39 -0
  50. package/templates/common/ci.yml +50 -0
  51. package/templates/common/codecov.yml +13 -0
  52. package/templates/common/gitignore.base +33 -0
  53. package/templates/common/pre-push +5 -0
  54. package/templates/fe/e2e.yml +22 -0
  55. package/templates/fe/gitignore +3 -0
  56. package/templates/fe/pre-commit +7 -0
  57. package/templates/fe/skeleton/apps/__app__/next.config.ts +11 -0
  58. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +22 -0
  59. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +31 -0
  60. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +15 -0
  61. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +27 -0
  62. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +24 -0
  63. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +1 -0
  64. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +10 -0
  65. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.ts +5 -0
  66. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/config.ts +8 -0
  67. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +19 -0
  68. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +27 -0
  69. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/navigation.ts +5 -0
  70. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +13 -0
  71. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +10 -0
  72. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.ts +9 -0
  73. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +10 -0
  74. package/templates/fe/sonar-project.properties +11 -0
@@ -0,0 +1,169 @@
1
+ HFS_CANON_PIN_DRIFT:
2
+ title: "Dependency version differs from the canon pin"
3
+ title_vi: "Phiên bản phụ thuộc lệch khỏi bản đã ghim"
4
+ meaning_vi: "Một phụ thuộc trong package.json không đúng phiên bản chính xác mà knowledge/hfs/canon-pins.yaml ghim cho cả họ kho; mọi gói @starci đều được cài từ npm registry đúng phiên bản đã ghim (không dùng file:)."
5
+ causes_vi:
6
+ - "Phiên bản viết dạng khoảng (^, ~) thay vì phiên bản chính xác"
7
+ - "Một workspace giữ phiên bản cũ của cùng phụ thuộc"
8
+ - "Một gói @starci khai báo dạng file: hoặc khoảng phiên bản thay vì phiên bản chính xác trên registry"
9
+ nextStep_vi: "Đặt phụ thuộc về đúng phiên bản chính xác ghi trong knowledge/hfs/canon-pins.yaml rồi cài lại từ npm registry. 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."
10
+ owner: op-retry
11
+ kind: check-finding
12
+
13
+ HFS_DECLARATION_INVALID:
14
+ title: "hfs.json missing or invalid"
15
+ title_vi: "hfs.json thiếu hoặc sai định dạng"
16
+ meaning_vi: "Kho mã không có tệp hfs.json hợp lệ nên không thể biết nó theo chuẩn HFS nào, có những ứng dụng nào và bật những ô tuỳ chọn nào."
17
+ causes_vi:
18
+ - "Thiếu tệp hfs.json ở gốc kho, hoặc tệp không đọc được như JSON"
19
+ - "Loại ứng dụng không có trong bảng kê của HFS, ví dụ một loại ứng dụng do kho tự đặt"
20
+ - "optionalSlots ghi một ô không phải loại tuỳ chọn, hoặc ghi một ô mà loại ứng dụng đã tự bật"
21
+ - "Thiếu loại ứng dụng bắt buộc, ví dụ chưa khai ứng dụng api hoặc chưa khai ứng dụng migrate dù đã khai kết nối cơ sở dữ liệu"
22
+ nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa hfs.json theo lời nhắn của bộ kiểm tra; không cần ai can thiệp thêm."
23
+ owner: op-retry
24
+ kind: check-finding
25
+
26
+ HFS_FORBIDDEN_PRESENT:
27
+ title: "Tracked path in a forbidden slot"
28
+ title_vi: "Có tệp bị cấm trong cây mã nguồn"
29
+ meaning_vi: "Tệp được theo dõi nằm trong một ô bị cấm (bộ nhớ đệm công cụ, đầu ra của tác tử, worktree, tệp bí mật dạng rõ). Ô này quy định nơi tệp phải ở, ngoài kho."
30
+ causes_vi:
31
+ - "Tác tử để lại tệp tạm hoặc báo cáo ở gốc kho rồi bị commit"
32
+ - "Tệp .env hoặc khoá bí mật dạng rõ nằm trong kho"
33
+ - "Thư mục e2e ở gốc thay vì src/tests/e2e"
34
+ nextStep_vi: "Chuyển tệp tới nơi ô đó chỉ định (trường goesTo) hoặc xoá nó, rồi gỡ khỏi Git; tệp bí mật phải được niêm phong đúng chỗ, không bao giờ để dạng rõ. 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."
35
+ owner: op-retry
36
+ kind: check-finding
37
+
38
+ HFS_INIT_EXISTS:
39
+ title: "hfs.json already exists"
40
+ title_vi: "hfs.json đã có sẵn"
41
+ meaning_vi: "Lệnh hfs init không ghi đè bản khai báo đã có vì bản đó là quyết định của kho."
42
+ causes_vi:
43
+ - "Chạy hfs init trong kho đã có hfs.json"
44
+ nextStep_vi: "Sửa hfs.json trực tiếp, hoặc dùng hfs init --stdout để xem bản dò tự động rồi so sánh."
45
+ owner: op-retry
46
+ kind: verb-refusal
47
+
48
+ HFS_INIT_UNDETECTED:
49
+ title: "Repository profile or apps not detected"
50
+ title_vi: "Không dò được loại kho hoặc ứng dụng"
51
+ meaning_vi: "Lệnh hfs init không xác định chắc chắn kho là backend hay frontend, hoặc không thấy ứng dụng nào dưới apps/ để khai báo, nên không đoán mò."
52
+ causes_vi:
53
+ - "Không có next hay @nestjs/core trong phụ thuộc, hoặc có cả hai"
54
+ - "Kho chưa có thư mục apps/<tên>/ (Nest cần thêm src/, Next cần next.config)"
55
+ nextStep_vi: "Tạo bố cục apps/<tên>/ theo chuẩn hoặc viết hfs.json bằng tay theo modules/schemas/hfs-repo.schema.yaml."
56
+ owner: op-retry
57
+ kind: verb-refusal
58
+
59
+ HFS_MANIFEST_INVALID:
60
+ title: "HFS slot manifest invalid"
61
+ title_vi: "Bảng kê ô HFS sai định dạng"
62
+ meaning_vi: "Tệp knowledge/hfs/slots.yaml của bộ chạy không đọc được hoặc vi phạm lược đồ của nó, nên mọi kiểm tra HFS bị từ chối chứ không chạy trên một bảng kê hỏng."
63
+ causes_vi:
64
+ - "Một ô thiếu trường bắt buộc, dùng giá trị ngoài danh sách cho phép hoặc trùng mã với ô khác"
65
+ - "Ma trận hướng import nhắc tới một tầng không tồn tại"
66
+ - "Hai ô cùng khai một mẫu đường dẫn, hoặc một ô đã ngừng dùng mà không chỉ ra ô kế nhiệm"
67
+ nextStep_vi: "Đây là lỗi của bộ chạy, không phải của sản phẩm; người giám sát sửa knowledge/hfs/slots.yaml qua một nhánh đã được duyệt, sản phẩm không tự sửa."
68
+ owner: supervisor
69
+ kind: check-finding
70
+
71
+ HFS_MANIFEST_MAJOR_MISMATCH:
72
+ title: "hfs.json pins another manifest major"
73
+ title_vi: "Phiên bản chính của bảng kê không khớp"
74
+ meaning_vi: "Trường hfs trong hfs.json ghim một phiên bản chính khác với bảng kê ô của bộ chạy. HFS không có giai đoạn tương thích: khi phiên bản chính mới được nạp thì phiên bản chính cũ bị từ chối ngay."
75
+ causes_vi:
76
+ - "Bộ chạy đã lên phiên bản chính mới của bảng kê nhưng kho chưa được di chuyển sang"
77
+ - "hfs.json ghi số phiên bản chính sai hoặc chép từ một kho khác"
78
+ nextStep_vi: "Chủ sở hữu duyệt một nhánh di chuyển cho kho này rồi mới đổi số hfs; không có cách bỏ qua hay giữ song song hai phiên bản."
79
+ owner: owner
80
+ kind: check-finding
81
+
82
+ HFS_MIN_INSTANCES:
83
+ title: "Fewer instances of a slot than required"
84
+ title_vi: "Số thực thể của ô ít hơn mức tối thiểu"
85
+ meaning_vi: "Một ô đòi có ít nhất một số thực thể (ví dụ ít nhất một tính năng trong src/features) nhưng kho có ít hơn."
86
+ causes_vi:
87
+ - "Kho chưa có tính năng nào đúng mẫu src/features/<tên>/"
88
+ - "Toàn bộ mã nghiệp vụ nằm ở chỗ ngoài mẫu nên không được tính"
89
+ nextStep_vi: "Chuyển mã nghiệp vụ vào thực thể đúng mẫu của ô hoặc tạo thực thể còn 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."
90
+ owner: op-retry
91
+ kind: check-finding
92
+
93
+ HFS_PATH_NO_SLOT:
94
+ title: "Path matches no HFS slot"
95
+ title_vi: "Đường dẫn không khớp ô nào của HFS"
96
+ meaning_vi: "Một tệp hoặc thư mục trong kho không thuộc ô nào trong bảng kê; mọi đường dẫn được theo dõi phải khớp đúng một ô. Bộ kiểm tra báo kèm ô gần nhất để biết tệp lẽ ra thuộc đâu."
97
+ causes_vi:
98
+ - "Tệp đặt sai chỗ, ví dụ một tệp không phải *.e2e-spec.ts nằm trong thư mục kiểm thử e2e"
99
+ - "Thư mục do kho tự đặt ở gốc hoặc trong src mà HFS không có ô cho nó"
100
+ - "Tên tệp hoặc thư mục viết sai so với mẫu của ô gần nhất"
101
+ nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để chuyển tệp vào ô gần nhất được báo hoặc xoá nó; nếu thực sự cần một loại nội dung mới thì đó là việc thêm ô vào bảng kê, do người giám sát làm, không phải kho tự thêm."
102
+ owner: op-retry
103
+ kind: check-finding
104
+
105
+ HFS_REPO_UNREADABLE:
106
+ title: "Repository is not a readable Git work tree"
107
+ title_vi: "Không đọc được kho Git"
108
+ meaning_vi: "Bộ kiểm tra HFS chỉ xét những gì Git theo dõi nên cần một cây làm việc Git đọc được; thư mục được chỉ định thì không."
109
+ causes_vi:
110
+ - "Đường dẫn --repo sai hoặc không phải kho Git"
111
+ - "Git chưa cài hoặc kho bị hỏng"
112
+ nextStep_vi: "Trỏ --repo vào gốc một kho Git hợp lệ rồi chạy lại."
113
+ owner: op-retry
114
+ kind: input-invalid
115
+
116
+ HFS_REQUIRED_MISSING:
117
+ title: "Required file or directory missing"
118
+ title_vi: "Thiếu tệp hoặc thư mục bắt buộc"
119
+ 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."
120
+ causes_vi:
121
+ - "Tính năng thiếu index.ts, tệp module hoặc thư mục application"
122
+ - "Ứng dụng thiếu main.ts, app.module.ts hoặc spec ghép ứng dụng"
123
+ - "Kho thiếu tệp gốc bắt buộc như README.md, hfs.json, .starciwork, .starcistacks"
124
+ 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."
125
+ owner: op-retry
126
+ kind: check-finding
127
+
128
+ HFS_SIZE_SOFT_BACKLOG:
129
+ title: "Source file above the soft size"
130
+ title_vi: "Tệp mã vượt cỡ mềm (chỉ báo cáo)"
131
+ meaning_vi: "Tệp mã nguồn dài hơn ngưỡng mềm của ruleParams.fileLines.soft. Đây là danh mục việc cần tách dần, chỉ báo cáo và không bao giờ làm bộ kiểm tra thất bại."
132
+ causes_vi:
133
+ - "Tệp tích tụ qua nhiều lần sửa mà chưa được tách"
134
+ nextStep_vi: "Không cần hành động ngay; khi sửa tệp này, tách theo trách nhiệm để không tăng thêm. Quy tắc tăng trưởng HFS_SIZE_GROWTH mới là chỗ chặn tệp vượt ngưỡng lớn thêm."
135
+ owner: op-retry
136
+ kind: check-finding
137
+
138
+ HFS_SLOT_AMBIGUOUS:
139
+ title: "Path owned equally by two slots"
140
+ title_vi: "Đường dẫn thuộc hai ô cùng mức cụ thể"
141
+ meaning_vi: "Hai ô của bảng kê cùng khớp một đường dẫn với độ cụ thể bằng nhau nên không xác định được ô nào sở hữu. Đây là chỗ hở của bảng kê, không phải lỗi của kho."
142
+ causes_vi:
143
+ - "Hai mẫu đường dẫn trong bảng kê chồng lên nhau"
144
+ - "Một ô mới thêm chưa loại phần trùng với ô cũ"
145
+ nextStep_vi: "Người giám sát sửa bảng kê để mỗi đường dẫn chỉ có một ô sở hữu; kho không tự xử lý. Bộ kiểm tra chạy lại sau khi bảng kê được sửa."
146
+ owner: op-retry
147
+ kind: check-finding
148
+
149
+ HFS_SLOT_NOT_ENABLED:
150
+ title: "Path in an opt-in slot the repository did not enable"
151
+ title_vi: "Đường dẫn thuộc ô tuỳ chọn mà kho chưa bật"
152
+ meaning_vi: "Tệp được theo dõi nằm trong một ô loại tuỳ chọn, nhưng hfs.json không liệt kê ô đó trong optionalSlots và không có ứng dụng nào thuộc loại tự bật ô đó."
153
+ causes_vi:
154
+ - "Kho dùng một loại nội dung tuỳ chọn (worker, websocket, tài liệu, gói dùng chung) mà chưa khai báo"
155
+ - "Ứng dụng của loại tương ứng chưa được ghi vào apps của hfs.json"
156
+ nextStep_vi: "Khai báo ô trong optionalSlots hoặc khai ứng dụng đúng loại ở hfs.json nếu nội dung này là thật; nếu không thì chuyển hoặc xoá tệp. 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."
157
+ owner: op-retry
158
+ kind: check-finding
159
+
160
+ HFS_TRACKED_MUST_BE_IGNORED:
161
+ title: "Tracked path that must be gitignored"
162
+ title_vi: "Tệp bị theo dõi nhưng lẽ ra phải bị bỏ qua"
163
+ meaning_vi: "Tệp nằm trong một ô loại ignored (kết quả build, tệp sinh tự động, phụ thuộc) nhưng đã được đưa vào Git."
164
+ causes_vi:
165
+ - "Thư mục dist, coverage, .next hoặc tệp sinh tự động bị git add"
166
+ - ".gitignore thiếu dòng cho thư mục này"
167
+ nextStep_vi: "Gỡ tệp khỏi Git bằng git rm --cached và thêm vào khối .gitignore chuẩn; không xoá thư mục trên đĩa. 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."
168
+ owner: op-retry
169
+ kind: check-finding
@@ -0,0 +1,23 @@
1
+ // glob.mjs — the path-glob subset ESLint, SonarQube and the scoped-lint config share: `**` spans directories,
2
+ // `*` and `?` stay inside one segment, `{a,b}` alternates.
3
+ import { posixPath } from './path-key.mjs';
4
+
5
+ /** Every alternative a `{a,b}` pattern spells, braces expanded left to right. */
6
+ export function braceVariants(value) {
7
+ const match = /\{([^{}]+)\}/.exec(value);
8
+ return match ? match[1].split(',').flatMap((part) => braceVariants(`${value.slice(0, match.index)}${part}${value.slice(match.index + match[0].length)}`)) : [value];
9
+ }
10
+
11
+ /** The anchored RegExp of one brace-free glob, read as a posix path (backslashes and a leading './' folded). */
12
+ export function globExpression(value) {
13
+ const input = posixPath(value);
14
+ let source = '';
15
+ for (let i = 0; i < input.length; i += 1) {
16
+ const c = input[i];
17
+ if (c === '*' && input[i + 1] === '*') { i += 1; if (input[i + 1] === '/') { i += 1; source += '(?:.*/)?'; } else source += '.*'; }
18
+ else if (c === '*') source += '[^/]*';
19
+ else if (c === '?') source += '[^/]';
20
+ else source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
21
+ }
22
+ return new RegExp(`^${source}$`);
23
+ }
@@ -0,0 +1,305 @@
1
+ // hfs-check.mjs - the HFS repository check, behind `hfs check | init | explain` (packages/hfs) and reusable by any
2
+ // runtime check. It reads three things and nothing else: the repository's hfs.json, the slot manifest
3
+ // (knowledge/hfs/slots.yaml through scripts/lib/hfs-slots.mjs) and the pins (knowledge/hfs/canon-pins.yaml). It never
4
+ // writes to the repository it inspects.
5
+ //
6
+ // checkRepo() answers, for the tracked paths of one repository (git ls-files):
7
+ // HFS_PATH_NO_SLOT a tracked path no slot owns (the nearest slot is named)
8
+ // HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
9
+ // HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
10
+ // HFS_TRACKED_MUST_BE_IGNORED a tracked path in an `ignored` slot (build output, generated files)
11
+ // HFS_FORBIDDEN_PRESENT a tracked path in a forbidden / `external` slot
12
+ // HFS_REQUIRED_MISSING a file or directory a required slot (or an instance of one) must contain
13
+ // HFS_MIN_INSTANCES fewer instances of a slot than minInstances
14
+ // HFS_CANON_PIN_DRIFT a dependency whose declared version is not the pinned one
15
+ // HFS_SIZE_SOFT_BACKLOG (info, report-only, never fails) a source file above ruleParams fileLines.soft
16
+ // Every finding carries its code and the Vietnamese why text of modules/kernel/failure-codes.yaml. Only `error`
17
+ // findings fail the check.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+ import { skillRoot } from '../../engine/runtime-root.mjs';
22
+ import { parseYaml } from '../../engine/yaml.mjs';
23
+ import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
24
+ import { posixPath } from './path-key.mjs';
25
+
26
+ export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
27
+ export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
28
+ /** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
29
+ export const CHECK_CODES = Object.freeze([
30
+ 'HFS_PATH_NO_SLOT', 'HFS_SLOT_NOT_ENABLED', 'HFS_SLOT_AMBIGUOUS', 'HFS_TRACKED_MUST_BE_IGNORED', 'HFS_FORBIDDEN_PRESENT',
31
+ 'HFS_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG',
32
+ 'HFS_INIT_EXISTS', 'HFS_INIT_UNDETECTED', 'HFS_REPO_UNREADABLE',
33
+ 'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID',
34
+ ]);
35
+ const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/;
36
+ const VAR = /<([a-z][a-z0-9-]*)>/g;
37
+ const DEP_SECTIONS = ['dependencies', 'devDependencies'];
38
+
39
+ const refuse = (code, message, details = {}) => { throw new HfsSlotsError(code, message, details); };
40
+
41
+ /** {code: {title_vi, meaning_vi, nextStep_vi}} for the codes asked for, read from the failure-code catalog under `root`. */
42
+ export function readWhy(root = skillRoot, codes = CHECK_CODES) {
43
+ const catalog = parseYaml(fs.readFileSync(path.join(root, FAILURE_CODES_FILE), 'utf8'));
44
+ const why = {};
45
+ for (const code of codes) {
46
+ const entry = catalog?.[code];
47
+ if (!entry) refuse('HFS_MANIFEST_INVALID', `${FAILURE_CODES_FILE} has no entry for ${code}`, { code });
48
+ why[code] = { titleVi: entry.title_vi, whyVi: entry.meaning_vi, nextStepVi: entry.nextStep_vi };
49
+ }
50
+ return why;
51
+ }
52
+
53
+ /** Repository-relative tracked paths (git ls-files, posix separators; what the index holds, whatever the work tree shows). A directory that is not a Git work tree is a refusal. */
54
+ export function trackedFiles(repoRoot) {
55
+ let out;
56
+ try {
57
+ out = execFileSync('git', ['-C', repoRoot, 'ls-files', '-z', '--cached', '--exclude-standard'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
58
+ } catch (error) {
59
+ refuse('HFS_REPO_UNREADABLE', `${repoRoot} is not a readable Git work tree (${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]})`, { repoRoot });
60
+ }
61
+ return out.split('\0').filter(Boolean).map(posixPath);
62
+ }
63
+
64
+ const fill = (text, bindings) => String(text).replace(VAR, (whole, name) => bindings[name] ?? whole);
65
+
66
+ /** hfs.json of `repoRoot` resolved against the manifest, or the single refusal finding when it is absent or invalid. */
67
+ export function openRepo({ repoRoot, root = skillRoot, manifest = loadSlotManifest({ root }) }) {
68
+ const repo = readRepoDeclaration(manifest, repoRoot);
69
+ return { manifest, repo, resolver: createSlotResolver(manifest, repo) };
70
+ }
71
+
72
+ const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
73
+
74
+ function pinFindings({ repoRoot, files, profile, root }) {
75
+ const pins = parseYaml(fs.readFileSync(path.join(root, CANON_PINS_FILE), 'utf8'))?.pins ?? {};
76
+ const findings = [];
77
+ for (const file of files.filter((f) => f === 'package.json' || f.endsWith('/package.json'))) {
78
+ let pkg;
79
+ try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
80
+ for (const [name, pin] of Object.entries(pins)) {
81
+ if (pin.side !== 'both' && pin.side !== profile) continue;
82
+ for (const section of DEP_SECTIONS) {
83
+ const spec = pkg[section]?.[name];
84
+ if (spec === undefined) continue;
85
+ const drift = pinnedSpec(spec, pin);
86
+ if (drift) findings.push({ code: 'HFS_CANON_PIN_DRIFT', level: 'error', path: file, dependency: name, section, pinned: pin.version, declared: spec, message: `${name} in ${file} ${section}: ${drift}` });
87
+ }
88
+ }
89
+ }
90
+ return findings;
91
+ }
92
+
93
+ /** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
94
+ function instancesOf(resolver, files) {
95
+ const found = new Map();
96
+ const note = (slotId, root, bindings) => {
97
+ const slot = resolver.slot(slotId);
98
+ if (!slot || !(slot.requires?.length || slot.minInstances)) return;
99
+ const key = `${slotId}|${root}`;
100
+ if (!found.has(key)) found.set(key, { slot: slotId, root, bindings });
101
+ };
102
+ for (const file of files) {
103
+ const c = resolver.classifyPath(file);
104
+ if (c.status === 'owned') note(c.slot, c.root, c.bindings);
105
+ const owner = c.status === 'owned' ? resolver.ownerOf(file) : null;
106
+ if (owner) note(owner.slot, owner.root, owner.bindings);
107
+ }
108
+ return [...found.values()];
109
+ }
110
+
111
+ const requiredOf = (slot, instance) => (slot.requires ?? []).map((entry) => {
112
+ const rooted = entry.startsWith('/');
113
+ const filled = fill(rooted ? entry.slice(1) : entry, instance.bindings);
114
+ return rooted || !instance.root ? filled : `${instance.root}/${filled}`;
115
+ });
116
+
117
+ const appOf = (p) => /^apps\/([^/]+)\//.exec(p)?.[1];
118
+
119
+ function withWhy(findings, why) {
120
+ return findings.map((f) => ({ ...f, titleVi: why[f.code].titleVi, whyVi: why[f.code].whyVi, nextStepVi: why[f.code].nextStepVi }));
121
+ }
122
+
123
+ function summarize(findings) {
124
+ const byCode = {};
125
+ for (const f of findings) {
126
+ byCode[f.code] ??= { level: f.level, count: 0 };
127
+ byCode[f.code].count += 1;
128
+ }
129
+ return {
130
+ error: findings.filter((f) => f.level === 'error').length,
131
+ info: findings.filter((f) => f.level === 'info').length,
132
+ byCode,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The check of one repository. `declaration` overrides hfs.json (a dry run over a repository that has none); `files`
138
+ * overrides git ls-files (specs). Returns {ok, profile, apps, manifest, tracked, findings, counts}; a missing or invalid
139
+ * hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
140
+ */
141
+ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, manifest = loadSlotManifest({ root }) }) {
142
+ const why = readWhy(root);
143
+ let repo;
144
+ try {
145
+ repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
146
+ } catch (error) {
147
+ if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
148
+ const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
149
+ return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
150
+ }
151
+ const resolver = createSlotResolver(manifest, repo);
152
+ const tracked = files ?? trackedFiles(repoRoot);
153
+ const trackedSet = new Set(tracked);
154
+ const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
155
+ const findings = [];
156
+
157
+ for (const file of tracked) {
158
+ const c = resolver.classifyPath(file);
159
+ if (c.status === 'no-slot') {
160
+ findings.push({ code: 'HFS_PATH_NO_SLOT', 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'}` : ''}` });
161
+ } else if (c.status === 'ambiguous') {
162
+ 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(', ')}` });
163
+ } else if (c.status === 'not-enabled') {
164
+ 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` });
165
+ } else if (c.status === 'forbidden') {
166
+ findings.push({ code: 'HFS_FORBIDDEN_PRESENT', 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}` : ''}` });
167
+ } else if (c.tracking === 'ignored') {
168
+ 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` });
169
+ }
170
+ }
171
+
172
+ const required = resolver.requiredPaths();
173
+ const missing = new Set();
174
+ const missingFile = (slot, p, via) => {
175
+ const key = `${slot}|${p}`;
176
+ if (missing.has(key) || present(p)) return;
177
+ missing.add(key);
178
+ const app = appOf(p);
179
+ findings.push({ code: 'HFS_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
180
+ };
181
+ for (const entry of required.paths) missingFile(entry.slot, entry.path, entry.via);
182
+ const instances = instancesOf(resolver, tracked);
183
+ for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
184
+ for (const { slot, min, appKind } of required.minimums) {
185
+ if (appKind !== undefined) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
186
+ const count = instances.filter((i) => i.slot === slot).length;
187
+ if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
188
+ }
189
+
190
+ findings.push(...pinFindings({ repoRoot, files: tracked, profile: repo.profile, root }));
191
+
192
+ const soft = resolver.ruleParams().fileLines.soft;
193
+ for (const file of tracked) {
194
+ if (!SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
195
+ let lines;
196
+ try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
197
+ if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
198
+ }
199
+
200
+ const finished = withWhy(findings, why);
201
+ const counts = summarize(finished);
202
+ return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps: repo.apps, tracked: tracked.length, findings: finished, counts };
203
+ }
204
+
205
+ // --------------------------------------------------------------------------------------------------- explain
206
+
207
+ const TEST_KIND = {
208
+ 'unit-beside': 'a unit spec beside each source file (<name>.spec.ts / .spec.tsx) in this slot',
209
+ e2e: 'an e2e spec (*.e2e-spec.ts or a Playwright spec) covering the flow; no unit spec is required',
210
+ none: 'no test is required for files in this slot',
211
+ };
212
+
213
+ /** What owns `input` and what that means: slot, tier, allowed imports, required tests, required files. */
214
+ export function explainPath({ repoRoot, input, root = skillRoot, declaration, manifest = loadSlotManifest({ root }) }) {
215
+ const repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
216
+ const resolver = createSlotResolver(manifest, repo);
217
+ const why = readWhy(root);
218
+ const c = resolver.classifyPath(input);
219
+ if (c.status === 'no-slot') {
220
+ return { path: c.path, status: 'no-slot', code: 'HFS_PATH_NO_SLOT', nearest: c.nearest, titleVi: why.HFS_PATH_NO_SLOT.titleVi, whyVi: why.HFS_PATH_NO_SLOT.whyVi };
221
+ }
222
+ if (c.status === 'ambiguous') return { path: c.path, status: 'ambiguous', code: 'HFS_SLOT_AMBIGUOUS', candidates: c.candidates, titleVi: why.HFS_SLOT_AMBIGUOUS.titleVi, whyVi: why.HFS_SLOT_AMBIGUOUS.whyVi };
223
+ const slot = resolver.slot(c.slot);
224
+ const tier = resolver.tierOf(c.path);
225
+ const owner = resolver.ownerOf(c.path);
226
+ const mayImport = tier && tier !== 'none' ? resolver.allowedImports(tier) : null;
227
+ return {
228
+ path: c.path,
229
+ status: c.status,
230
+ slot: slot.id,
231
+ pattern: slot.path,
232
+ presence: slot.presence,
233
+ tracking: slot.tracked,
234
+ tier: tier ?? 'none',
235
+ owner: owner ? { slot: owner.slot, root: owner.root } : null,
236
+ allowedImports: mayImport,
237
+ importRule: mayImport ? manifest.crossOwner : (tier === 'none' ? 'the slot takes no part in import checks' : null),
238
+ tests: slot.tests,
239
+ testsMeaning: TEST_KIND[slot.tests],
240
+ requiredFiles: resolver.requiredFiles(c.path),
241
+ ...(slot.goesTo ? { goesTo: slot.goesTo } : {}),
242
+ ...(slot.rules ? { rules: slot.rules } : {}),
243
+ ...(c.status === 'forbidden' ? { code: 'HFS_FORBIDDEN_PRESENT', titleVi: why.HFS_FORBIDDEN_PRESENT.titleVi, whyVi: why.HFS_FORBIDDEN_PRESENT.whyVi } : {}),
244
+ ...(c.status === 'not-enabled' ? { code: 'HFS_SLOT_NOT_ENABLED', titleVi: why.HFS_SLOT_NOT_ENABLED.titleVi, whyVi: why.HFS_SLOT_NOT_ENABLED.whyVi } : {}),
245
+ };
246
+ }
247
+
248
+ // ------------------------------------------------------------------------------------------------- init
249
+
250
+ const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
251
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
252
+ const exists = (p) => fs.existsSync(p);
253
+ const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
254
+ const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
255
+
256
+ /**
257
+ * A starter hfs.json by detection: profile from the dependencies (next -> fe, @nestjs/core -> be, over the root and
258
+ * every apps/<name>), apps from the apps/<name> directories (fe: kind next; be: worker/migrate/cli by name, else api).
259
+ * optionalSlots are the opt-in slots (not implied by an app kind) that tracked files already occupy. Connections are not guessed; a repository that keeps a database declares them by hand and gains the migrate app.
260
+ * A repository the detection cannot classify is HFS_INIT_UNDETECTED, never a guess.
261
+ */
262
+ export function detectDeclaration({ repoRoot, manifest }) {
263
+ const appsDir = path.join(repoRoot, 'apps');
264
+ const names = isDir(appsDir) ? fs.readdirSync(appsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort() : [];
265
+ const rootPkg = readPackage(repoRoot);
266
+ const all = { ...depsOf(rootPkg) };
267
+ for (const name of names) Object.assign(all, depsOf(readPackage(path.join(appsDir, name))));
268
+ const isFe = 'next' in all || names.some((n) => ['next.config.ts', 'next.config.js', 'next.config.mjs'].some((f) => exists(path.join(appsDir, n, f))));
269
+ const isBe = '@nestjs/core' in all || exists(path.join(repoRoot, 'nest-cli.json'));
270
+ if (isFe === isBe) refuse('HFS_INIT_UNDETECTED', `cannot tell the profile of ${repoRoot}: ${isFe ? 'both next and Nest are present' : 'neither next nor @nestjs/core is declared'}`, { repoRoot });
271
+ const profile = isFe ? 'fe' : 'be';
272
+ const apps = names.map((name) => {
273
+ if (profile === 'fe') return { name, kind: 'next' };
274
+ if (!isDir(path.join(appsDir, name, 'src'))) return null;
275
+ const kind = /migrat/.test(name) ? 'migrate' : (/worker/.test(name) ? 'worker' : (/(^|-)cli($|-)/.test(name) ? 'cli' : 'api'));
276
+ return { name, kind };
277
+ }).filter(Boolean).filter((a) => manifest.appKinds[profile].includes(a.kind));
278
+ if (!apps.length) refuse('HFS_INIT_UNDETECTED', `${repoRoot} has no apps/<name> ${profile === 'fe' ? 'Next application' : 'Nest application with a src directory'} to declare`, { repoRoot, profile });
279
+ const project = String(rootPkg?.name ?? path.basename(repoRoot)).replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(SLUG_SUFFIX, '');
280
+ const declaration = { hfs: manifest.major, profile, project: /^[a-z]/.test(project) ? project : `p-${project}`, apps };
281
+ const optionalSlots = occupiedOptInSlots({ repoRoot, manifest, declaration });
282
+ return optionalSlots.length ? { ...declaration, optionalSlots } : declaration;
283
+ }
284
+
285
+ /** The opt-in slots (not enabled by an app kind) that at least one tracked file falls in, found by enabling them all for a look. */
286
+ function occupiedOptInSlots({ repoRoot, manifest, declaration }) {
287
+ let files;
288
+ try { files = trackedFiles(repoRoot); } catch (error) { if (error.code === 'HFS_REPO_UNREADABLE') return []; throw error; }
289
+ const optIn = manifest.slots.filter((s) => s.profiles.includes(declaration.profile) && s.presence === 'opt-in' && s.appKind === undefined).map((s) => s.id);
290
+ const everything = createSlotResolver(manifest, resolveRepoDeclaration(manifest, { ...declaration, optionalSlots: optIn }));
291
+ const used = new Set();
292
+ for (const file of files) { const c = everything.classifyPath(file); if (c.status === 'owned' && optIn.includes(c.slot)) used.add(c.slot); }
293
+ return optIn.filter((id) => used.has(id));
294
+ }
295
+
296
+ /** Write hfs.json unless one exists (HFS_INIT_EXISTS); `write: false` returns the text only. */
297
+ export function initRepo({ repoRoot, root = skillRoot, write = true, manifest = loadSlotManifest({ root }) }) {
298
+ const file = path.join(repoRoot, HFS_DECLARATION_FILE);
299
+ if (write && exists(file)) refuse('HFS_INIT_EXISTS', `${file} already exists; init never rewrites a declaration`, { file });
300
+ const declaration = detectDeclaration({ repoRoot, manifest });
301
+ resolveRepoDeclaration(manifest, declaration);
302
+ const text = `${JSON.stringify(declaration, null, 2)}\n`;
303
+ if (write) fs.writeFileSync(file, text);
304
+ return { file, declaration, text, written: write };
305
+ }