@sha3/code 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.
- package/AGENTS.md +75 -0
- package/README.md +554 -0
- package/ai/adapters/codex.md +7 -0
- package/ai/adapters/copilot.md +7 -0
- package/ai/adapters/cursor.md +7 -0
- package/ai/adapters/windsurf.md +8 -0
- package/ai/constitution.md +12 -0
- package/bin/code-standards.mjs +47 -0
- package/biome.json +37 -0
- package/index.mjs +11 -0
- package/lib/cli/parse-args.mjs +416 -0
- package/lib/cli/post-run-guidance.mjs +43 -0
- package/lib/cli/run-init.mjs +123 -0
- package/lib/cli/run-profile.mjs +46 -0
- package/lib/cli/run-refactor.mjs +152 -0
- package/lib/cli/run-verify.mjs +67 -0
- package/lib/constants.mjs +167 -0
- package/lib/contract/load-rule-catalog.mjs +12 -0
- package/lib/contract/render-agents.mjs +79 -0
- package/lib/contract/render-contract-json.mjs +7 -0
- package/lib/contract/resolve-contract.mjs +52 -0
- package/lib/paths.mjs +50 -0
- package/lib/profile.mjs +108 -0
- package/lib/project/ai-instructions.mjs +28 -0
- package/lib/project/biome-ignore.mjs +14 -0
- package/lib/project/managed-files.mjs +105 -0
- package/lib/project/package-metadata.mjs +132 -0
- package/lib/project/prompt-files.mjs +111 -0
- package/lib/project/template-resolution.mjs +70 -0
- package/lib/refactor/materialize-refactor-context.mjs +106 -0
- package/lib/refactor/preservation-questions.mjs +33 -0
- package/lib/refactor/public-contract-extractor.mjs +22 -0
- package/lib/refactor/render-analysis-summary.mjs +50 -0
- package/lib/refactor/source-analysis.mjs +74 -0
- package/lib/utils/fs.mjs +220 -0
- package/lib/utils/prompts.mjs +63 -0
- package/lib/utils/text.mjs +43 -0
- package/lib/verify/change-audit-verifier.mjs +140 -0
- package/lib/verify/change-context.mjs +36 -0
- package/lib/verify/error-handling-verifier.mjs +164 -0
- package/lib/verify/explain-rule.mjs +54 -0
- package/lib/verify/issue-helpers.mjs +132 -0
- package/lib/verify/project-layout-verifier.mjs +259 -0
- package/lib/verify/project-verifier.mjs +267 -0
- package/lib/verify/readme-public-api.mjs +237 -0
- package/lib/verify/readme-verifier.mjs +216 -0
- package/lib/verify/render-json-report.mjs +3 -0
- package/lib/verify/render-text-report.mjs +34 -0
- package/lib/verify/source-analysis.mjs +126 -0
- package/lib/verify/source-rule-verifier.mjs +453 -0
- package/lib/verify/testing-verifier.mjs +113 -0
- package/lib/verify/tooling-verifier.mjs +82 -0
- package/lib/verify/typescript-style-verifier.mjs +407 -0
- package/package.json +55 -0
- package/profiles/default.profile.json +40 -0
- package/profiles/schema.json +96 -0
- package/prompts/init-contract.md +25 -0
- package/prompts/init-phase-2-implement.md +25 -0
- package/prompts/init-phase-3-verify.md +23 -0
- package/prompts/init.prompt.md +24 -0
- package/prompts/refactor-contract.md +26 -0
- package/prompts/refactor-phase-2-rebuild.md +25 -0
- package/prompts/refactor-phase-3-verify.md +24 -0
- package/prompts/refactor.prompt.md +26 -0
- package/resources/ai/AGENTS.md +18 -0
- package/resources/ai/adapters/codex.md +5 -0
- package/resources/ai/adapters/copilot.md +5 -0
- package/resources/ai/adapters/cursor.md +5 -0
- package/resources/ai/adapters/windsurf.md +5 -0
- package/resources/ai/contract.schema.json +68 -0
- package/resources/ai/rule-catalog.json +878 -0
- package/resources/ai/rule-catalog.schema.json +66 -0
- package/resources/ai/templates/adapters/codex.template.md +7 -0
- package/resources/ai/templates/adapters/copilot.template.md +7 -0
- package/resources/ai/templates/adapters/cursor.template.md +7 -0
- package/resources/ai/templates/adapters/windsurf.template.md +7 -0
- package/resources/ai/templates/agents.project.template.md +141 -0
- package/resources/ai/templates/examples/demo/src/billing/billing.service.ts +73 -0
- package/resources/ai/templates/examples/demo/src/config.ts +3 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.errors.ts +51 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.service.ts +96 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.types.ts +9 -0
- package/resources/ai/templates/examples/rules/async-bad.ts +52 -0
- package/resources/ai/templates/examples/rules/async-good.ts +56 -0
- package/resources/ai/templates/examples/rules/class-first-bad.ts +36 -0
- package/resources/ai/templates/examples/rules/class-first-good.ts +74 -0
- package/resources/ai/templates/examples/rules/constructor-bad.ts +68 -0
- package/resources/ai/templates/examples/rules/constructor-good.ts +71 -0
- package/resources/ai/templates/examples/rules/control-flow-bad.ts +31 -0
- package/resources/ai/templates/examples/rules/control-flow-good.ts +54 -0
- package/resources/ai/templates/examples/rules/errors-bad.ts +42 -0
- package/resources/ai/templates/examples/rules/errors-good.ts +23 -0
- package/resources/ai/templates/examples/rules/functions-bad.ts +48 -0
- package/resources/ai/templates/examples/rules/functions-good.ts +58 -0
- package/resources/ai/templates/examples/rules/returns-bad.ts +38 -0
- package/resources/ai/templates/examples/rules/returns-good.ts +44 -0
- package/resources/ai/templates/examples/rules/testing-bad.ts +34 -0
- package/resources/ai/templates/examples/rules/testing-good.ts +54 -0
- package/resources/ai/templates/rules/architecture.md +41 -0
- package/resources/ai/templates/rules/async.md +13 -0
- package/resources/ai/templates/rules/class-first.md +45 -0
- package/resources/ai/templates/rules/control-flow.md +13 -0
- package/resources/ai/templates/rules/errors.md +18 -0
- package/resources/ai/templates/rules/functions.md +29 -0
- package/resources/ai/templates/rules/naming.md +13 -0
- package/resources/ai/templates/rules/readme.md +36 -0
- package/resources/ai/templates/rules/returns.md +13 -0
- package/resources/ai/templates/rules/testing.md +18 -0
- package/resources/ai/templates/rules.project.template.md +66 -0
- package/resources/ai/templates/skills/change-synchronization/SKILL.md +42 -0
- package/resources/ai/templates/skills/feature-shaping/SKILL.md +45 -0
- package/resources/ai/templates/skills/http-api-conventions/SKILL.md +171 -0
- package/resources/ai/templates/skills/init-workflow/SKILL.md +52 -0
- package/resources/ai/templates/skills/readme-authoring/SKILL.md +51 -0
- package/resources/ai/templates/skills/refactor-workflow/SKILL.md +50 -0
- package/resources/ai/templates/skills/simplicity-audit/SKILL.md +41 -0
- package/resources/ai/templates/skills/test-scope-selection/SKILL.md +50 -0
- package/resources/ai/templates/skills.index.template.md +25 -0
- package/standards/architecture.md +72 -0
- package/standards/changelog-policy.md +12 -0
- package/standards/manifest.json +36 -0
- package/standards/readme.md +56 -0
- package/standards/schema.json +124 -0
- package/standards/style.md +106 -0
- package/standards/testing.md +20 -0
- package/standards/tooling.md +38 -0
- package/templates/node-lib/.biomeignore +10 -0
- package/templates/node-lib/.vscode/extensions.json +1 -0
- package/templates/node-lib/.vscode/settings.json +9 -0
- package/templates/node-lib/README.md +172 -0
- package/templates/node-lib/biome.json +37 -0
- package/templates/node-lib/gitignore +6 -0
- package/templates/node-lib/package.json +32 -0
- package/templates/node-lib/scripts/release-publish.mjs +106 -0
- package/templates/node-lib/scripts/run-tests.mjs +65 -0
- package/templates/node-lib/src/config.ts +3 -0
- package/templates/node-lib/src/index.ts +2 -0
- package/templates/node-lib/src/logger.ts +7 -0
- package/templates/node-lib/src/package-info/package-info.service.ts +47 -0
- package/templates/node-lib/test/package-info.test.ts +10 -0
- package/templates/node-lib/tsconfig.build.json +1 -0
- package/templates/node-lib/tsconfig.json +5 -0
- package/templates/node-service/.biomeignore +10 -0
- package/templates/node-service/.vscode/extensions.json +1 -0
- package/templates/node-service/.vscode/settings.json +9 -0
- package/templates/node-service/README.md +244 -0
- package/templates/node-service/biome.json +37 -0
- package/templates/node-service/ecosystem.config.cjs +3 -0
- package/templates/node-service/gitignore +6 -0
- package/templates/node-service/package.json +42 -0
- package/templates/node-service/scripts/release-publish.mjs +106 -0
- package/templates/node-service/scripts/run-tests.mjs +65 -0
- package/templates/node-service/src/app/service-runtime.service.ts +57 -0
- package/templates/node-service/src/app-info/app-info.service.ts +47 -0
- package/templates/node-service/src/config.ts +11 -0
- package/templates/node-service/src/http/http-server.service.ts +66 -0
- package/templates/node-service/src/index.ts +2 -0
- package/templates/node-service/src/logger.ts +7 -0
- package/templates/node-service/src/main.ts +5 -0
- package/templates/node-service/test/service-runtime.test.ts +13 -0
- package/templates/node-service/tsconfig.build.json +1 -0
- package/templates/node-service/tsconfig.json +5 -0
- package/tsconfig/base.json +16 -0
- package/tsconfig/node-lib.json +5 -0
- package/tsconfig/node-service.json +1 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
import { createVerificationIssue, getActiveVerifyRuleIds, shouldRunRule } from "./issue-helpers.mjs";
|
|
6
|
+
import { getIdentifierText, isExportedClass, loadProjectSourceFiles, visitNodes, visitNonNestedStatements } from "./source-analysis.mjs";
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN_IDENTIFIER_NAMES = new Set(["data", "obj", "tmp", "val", "thing", "helper", "utils", "common"]);
|
|
9
|
+
const BOOLEAN_PREFIX_PATTERN = /^(is|has|can|should)[A-Z0-9_]/;
|
|
10
|
+
const FEATURE_ROLE_SUFFIXES = new Set(["service", "types", "helpers", "errors", "repository", "controller", "handler", "mapper", "schema", "runtime"]);
|
|
11
|
+
const ROOT_SOURCE_EXEMPTIONS = new Set(["config.ts", "index.ts", "logger.ts", "main.ts"]);
|
|
12
|
+
|
|
13
|
+
function countReturnsInBody(body) {
|
|
14
|
+
let returnCount = 0;
|
|
15
|
+
|
|
16
|
+
visitNonNestedStatements(body, (node) => {
|
|
17
|
+
if (ts.isReturnStatement(node)) {
|
|
18
|
+
returnCount += 1;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return returnCount;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isBooleanLikeDeclaration(node) {
|
|
26
|
+
if (node.type && node.type.kind === ts.SyntaxKind.BooleanKeyword) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return node.initializer ? node.initializer.kind === ts.SyntaxKind.TrueKeyword || node.initializer.kind === ts.SyntaxKind.FalseKeyword : false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SECTION_MARKER_PATTERN = /\/\*\*\s*\n\s*\* @section\s+([a-z:]+)\s*\n\s*\*\//g;
|
|
34
|
+
|
|
35
|
+
function collectSectionMarkers(raw) {
|
|
36
|
+
return [...raw.matchAll(SECTION_MARKER_PATTERN)].map((match) => ({
|
|
37
|
+
name: match[1],
|
|
38
|
+
startIndex: match.index,
|
|
39
|
+
endIndex: (match.index ?? 0) + match[0].length,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripCommentsAndWhitespace(raw) {
|
|
44
|
+
return raw
|
|
45
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
46
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findEmptySectionMarkers(raw, markers) {
|
|
51
|
+
const failures = [];
|
|
52
|
+
|
|
53
|
+
for (const [index, marker] of markers.entries()) {
|
|
54
|
+
const nextMarker = markers[index + 1];
|
|
55
|
+
const sectionBody = raw.slice(marker.endIndex, nextMarker?.startIndex ?? raw.length);
|
|
56
|
+
|
|
57
|
+
if (stripCommentsAndWhitespace(sectionBody).length === 0) {
|
|
58
|
+
failures.push(
|
|
59
|
+
createVerificationIssue({
|
|
60
|
+
ruleId: "class-section-order",
|
|
61
|
+
category: "source-rule",
|
|
62
|
+
relativePath: "",
|
|
63
|
+
message: `empty @section block must be omitted: ${marker.name}`,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return failures;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function verifyClassSectionPlacement(relativePath, raw, markers, exportedClass) {
|
|
73
|
+
const classMarker = markers.find((marker) => marker.name === "class");
|
|
74
|
+
|
|
75
|
+
if (!classMarker) {
|
|
76
|
+
return [
|
|
77
|
+
createVerificationIssue({
|
|
78
|
+
ruleId: "class-section-order",
|
|
79
|
+
category: "source-rule",
|
|
80
|
+
relativePath,
|
|
81
|
+
message: "public class files must declare @section class immediately before the exported class",
|
|
82
|
+
}),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const classStartIndex = exportedClass.getStart(exportedClass.getSourceFile());
|
|
87
|
+
|
|
88
|
+
if (classMarker.startIndex > classStartIndex) {
|
|
89
|
+
return [
|
|
90
|
+
createVerificationIssue({
|
|
91
|
+
ruleId: "class-section-order",
|
|
92
|
+
category: "source-rule",
|
|
93
|
+
relativePath,
|
|
94
|
+
message: "@section class must appear before the exported class declaration",
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const between = raw.slice(classMarker.endIndex, classStartIndex);
|
|
100
|
+
|
|
101
|
+
return between.trim().length === 0
|
|
102
|
+
? []
|
|
103
|
+
: [
|
|
104
|
+
createVerificationIssue({
|
|
105
|
+
ruleId: "class-section-order",
|
|
106
|
+
category: "source-rule",
|
|
107
|
+
relativePath,
|
|
108
|
+
message: "@section class must appear immediately before the exported class declaration with no intervening comments",
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function verifySectionOrder(relativePath, raw, sectionOrder, exportedClass) {
|
|
114
|
+
const markers = collectSectionMarkers(raw);
|
|
115
|
+
|
|
116
|
+
if (markers.length === 0) {
|
|
117
|
+
return [
|
|
118
|
+
createVerificationIssue({
|
|
119
|
+
ruleId: "class-section-order",
|
|
120
|
+
category: "source-rule",
|
|
121
|
+
relativePath,
|
|
122
|
+
message: "public class files must include ordered @section markers",
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const failures = [];
|
|
128
|
+
let lastIndex = -1;
|
|
129
|
+
|
|
130
|
+
for (const marker of markers) {
|
|
131
|
+
const markerIndex = sectionOrder.indexOf(marker.name);
|
|
132
|
+
|
|
133
|
+
if (markerIndex < 0) {
|
|
134
|
+
failures.push(
|
|
135
|
+
createVerificationIssue({
|
|
136
|
+
ruleId: "class-section-order",
|
|
137
|
+
category: "source-rule",
|
|
138
|
+
relativePath,
|
|
139
|
+
message: `unknown @section marker: ${marker.name}`,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (markerIndex < lastIndex) {
|
|
146
|
+
failures.push(
|
|
147
|
+
createVerificationIssue({
|
|
148
|
+
ruleId: "class-section-order",
|
|
149
|
+
category: "source-rule",
|
|
150
|
+
relativePath,
|
|
151
|
+
message: `out-of-order @section marker: ${marker.name}`,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
lastIndex = markerIndex;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const issue of findEmptySectionMarkers(raw, markers)) {
|
|
160
|
+
failures.push({ ...issue, relativePath });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
failures.push(...verifyClassSectionPlacement(relativePath, raw, markers, exportedClass));
|
|
164
|
+
|
|
165
|
+
return failures;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function verifyFeatureFilename(relativePath) {
|
|
169
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
170
|
+
|
|
171
|
+
if (!normalizedPath.startsWith("src/")) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const segments = normalizedPath.split("/");
|
|
176
|
+
const fileName = segments.at(-1);
|
|
177
|
+
|
|
178
|
+
if (segments.length <= 2 || ROOT_SOURCE_EXEMPTIONS.has(fileName)) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const roleMatch = fileName.match(/\.([a-z]+)\.ts$/);
|
|
183
|
+
if (!roleMatch || !FEATURE_ROLE_SUFFIXES.has(roleMatch[1])) {
|
|
184
|
+
return [
|
|
185
|
+
createVerificationIssue({
|
|
186
|
+
ruleId: "feature-filename-role",
|
|
187
|
+
category: "source-rule",
|
|
188
|
+
relativePath,
|
|
189
|
+
message: "feature files must include an explicit role suffix such as .service.ts, .types.ts, or .helpers.ts",
|
|
190
|
+
}),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function verifyFeatureClassOnly(relativePath, ast) {
|
|
198
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
199
|
+
|
|
200
|
+
if (!normalizedPath.startsWith("src/")) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const segments = normalizedPath.split("/");
|
|
205
|
+
const fileName = segments.at(-1);
|
|
206
|
+
|
|
207
|
+
if (segments.length <= 2 || ROOT_SOURCE_EXEMPTIONS.has(fileName) || fileName.endsWith(".types.ts")) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const exportedClasses = ast.statements.filter((statement) => isExportedClass(statement));
|
|
212
|
+
|
|
213
|
+
if (exportedClasses.length !== 1) {
|
|
214
|
+
return [
|
|
215
|
+
createVerificationIssue({
|
|
216
|
+
ruleId: "feature-class-only",
|
|
217
|
+
category: "source-rule",
|
|
218
|
+
relativePath,
|
|
219
|
+
message: "feature files must expose exactly one public class; only .types.ts files may expose non-class exports",
|
|
220
|
+
}),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function verifyNoModuleFunctionsInClassFiles(relativePath, ast) {
|
|
228
|
+
const exportedClasses = ast.statements.filter((statement) => isExportedClass(statement));
|
|
229
|
+
|
|
230
|
+
if (exportedClasses.length === 0) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const issues = [];
|
|
235
|
+
|
|
236
|
+
for (const statement of ast.statements) {
|
|
237
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
238
|
+
issues.push(
|
|
239
|
+
createVerificationIssue({
|
|
240
|
+
ruleId: "no-module-functions-in-class-files",
|
|
241
|
+
category: "source-rule",
|
|
242
|
+
relativePath,
|
|
243
|
+
message: `class files must not define module-scope helper functions: ${statement.name.text}`,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!ts.isVariableStatement(statement)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
254
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
|
|
259
|
+
issues.push(
|
|
260
|
+
createVerificationIssue({
|
|
261
|
+
ruleId: "no-module-functions-in-class-files",
|
|
262
|
+
category: "source-rule",
|
|
263
|
+
relativePath,
|
|
264
|
+
message: `class files must not define module-scope helper functions: ${declaration.name.text}`,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return issues;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function verifyConfigImport(relativePath, node) {
|
|
275
|
+
const specifier = node.moduleSpecifier.text;
|
|
276
|
+
|
|
277
|
+
if (!specifier.startsWith(".") || !/(^|\/)config(?:\.ts)?$/.test(specifier)) {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const failures = [];
|
|
282
|
+
const importClause = node.importClause;
|
|
283
|
+
|
|
284
|
+
if (!specifier.endsWith("config.ts")) {
|
|
285
|
+
failures.push(
|
|
286
|
+
createVerificationIssue({
|
|
287
|
+
ruleId: "canonical-config-import",
|
|
288
|
+
category: "source-rule",
|
|
289
|
+
relativePath,
|
|
290
|
+
message: "config imports must include the .ts extension",
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!importClause || !importClause.name || importClause.name.text !== "config" || importClause.namedBindings) {
|
|
296
|
+
failures.push(
|
|
297
|
+
createVerificationIssue({
|
|
298
|
+
ruleId: "canonical-config-import",
|
|
299
|
+
category: "source-rule",
|
|
300
|
+
relativePath,
|
|
301
|
+
message: 'config imports must use `import config from ".../config.ts"`',
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return failures;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function verifyIdentifier(ruleId, relativePath, name) {
|
|
310
|
+
if (!name) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return FORBIDDEN_IDENTIFIER_NAMES.has(name)
|
|
315
|
+
? [
|
|
316
|
+
createVerificationIssue({
|
|
317
|
+
ruleId,
|
|
318
|
+
category: "source-rule",
|
|
319
|
+
relativePath,
|
|
320
|
+
message: `forbidden generic identifier: ${name}`,
|
|
321
|
+
}),
|
|
322
|
+
]
|
|
323
|
+
: [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function verifyBooleanName(relativePath, name) {
|
|
327
|
+
if (!name || BOOLEAN_PREFIX_PATTERN.test(name)) {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return [
|
|
332
|
+
createVerificationIssue({
|
|
333
|
+
ruleId: "boolean-prefix",
|
|
334
|
+
category: "source-rule",
|
|
335
|
+
relativePath,
|
|
336
|
+
message: `boolean identifier must start with is/has/can/should: ${name}`,
|
|
337
|
+
}),
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function verifySourceRules(targetPath, contract, options = {}) {
|
|
342
|
+
const activeRuleIds = getActiveVerifyRuleIds(contract);
|
|
343
|
+
const sectionOrder = Array.isArray(contract.profile.comment_section_blocks) ? contract.profile.comment_section_blocks : [];
|
|
344
|
+
const files = options.analysisContext?.sourceFiles ?? (await loadProjectSourceFiles(targetPath, options));
|
|
345
|
+
const errors = [];
|
|
346
|
+
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
const { relativePath, ast, raw } = file;
|
|
349
|
+
const isSourceFile = relativePath.startsWith("src/");
|
|
350
|
+
const isTransportSourceFile = relativePath.startsWith("src/http/");
|
|
351
|
+
const exportedClasses = ast.statements.filter((statement) => isExportedClass(statement));
|
|
352
|
+
|
|
353
|
+
if (
|
|
354
|
+
isSourceFile &&
|
|
355
|
+
activeRuleIds.has("one-public-class-per-file") &&
|
|
356
|
+
shouldRunRule(options.onlyRuleIds, "one-public-class-per-file") &&
|
|
357
|
+
exportedClasses.length > 1
|
|
358
|
+
) {
|
|
359
|
+
errors.push(
|
|
360
|
+
createVerificationIssue({
|
|
361
|
+
ruleId: "one-public-class-per-file",
|
|
362
|
+
category: "source-rule",
|
|
363
|
+
relativePath,
|
|
364
|
+
message: "source files may expose at most one public class",
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (isSourceFile && activeRuleIds.has("class-section-order") && shouldRunRule(options.onlyRuleIds, "class-section-order") && exportedClasses.length > 0) {
|
|
370
|
+
errors.push(...verifySectionOrder(relativePath, raw, sectionOrder, exportedClasses[0]));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (isSourceFile && activeRuleIds.has("feature-filename-role") && shouldRunRule(options.onlyRuleIds, "feature-filename-role")) {
|
|
374
|
+
errors.push(...verifyFeatureFilename(relativePath));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (isSourceFile && activeRuleIds.has("feature-class-only") && shouldRunRule(options.onlyRuleIds, "feature-class-only")) {
|
|
378
|
+
errors.push(...verifyFeatureClassOnly(relativePath, ast));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (isSourceFile && activeRuleIds.has("no-module-functions-in-class-files") && shouldRunRule(options.onlyRuleIds, "no-module-functions-in-class-files")) {
|
|
382
|
+
errors.push(...verifyNoModuleFunctionsInClassFiles(relativePath, ast));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
visitNodes(ast, (node) => {
|
|
386
|
+
if (isSourceFile && !isTransportSourceFile && activeRuleIds.has("single-return") && shouldRunRule(options.onlyRuleIds, "single-return")) {
|
|
387
|
+
if (
|
|
388
|
+
ts.isFunctionDeclaration(node) ||
|
|
389
|
+
ts.isFunctionExpression(node) ||
|
|
390
|
+
ts.isArrowFunction(node) ||
|
|
391
|
+
ts.isMethodDeclaration(node) ||
|
|
392
|
+
ts.isConstructorDeclaration(node)
|
|
393
|
+
) {
|
|
394
|
+
if (node.body && countReturnsInBody(node.body) > 1) {
|
|
395
|
+
errors.push(
|
|
396
|
+
createVerificationIssue({
|
|
397
|
+
ruleId: "single-return",
|
|
398
|
+
category: "source-rule",
|
|
399
|
+
relativePath,
|
|
400
|
+
message: "functions and methods outside src/http/ must use a single return statement",
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (
|
|
408
|
+
isSourceFile &&
|
|
409
|
+
activeRuleIds.has("async-await-only") &&
|
|
410
|
+
shouldRunRule(options.onlyRuleIds, "async-await-only") &&
|
|
411
|
+
ts.isCallExpression(node) &&
|
|
412
|
+
ts.isPropertyAccessExpression(node.expression)
|
|
413
|
+
) {
|
|
414
|
+
const callName = node.expression.name.text;
|
|
415
|
+
|
|
416
|
+
if (callName === "then" || callName === "catch") {
|
|
417
|
+
errors.push(
|
|
418
|
+
createVerificationIssue({
|
|
419
|
+
ruleId: "async-await-only",
|
|
420
|
+
category: "source-rule",
|
|
421
|
+
relativePath,
|
|
422
|
+
message: "promise chains are forbidden in src/; use async/await instead",
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (
|
|
429
|
+
isSourceFile &&
|
|
430
|
+
activeRuleIds.has("canonical-config-import") &&
|
|
431
|
+
shouldRunRule(options.onlyRuleIds, "canonical-config-import") &&
|
|
432
|
+
ts.isImportDeclaration(node) &&
|
|
433
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
434
|
+
) {
|
|
435
|
+
errors.push(...verifyConfigImport(relativePath, node));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (activeRuleIds.has("domain-specific-identifiers") && shouldRunRule(options.onlyRuleIds, "domain-specific-identifiers")) {
|
|
439
|
+
if (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isPropertyDeclaration(node) || ts.isBindingElement(node)) {
|
|
440
|
+
errors.push(...verifyIdentifier("domain-specific-identifiers", relativePath, getIdentifierText(node.name)));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (activeRuleIds.has("boolean-prefix") && shouldRunRule(options.onlyRuleIds, "boolean-prefix")) {
|
|
445
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isPropertyDeclaration(node)) && isBooleanLikeDeclaration(node)) {
|
|
446
|
+
errors.push(...verifyBooleanName(relativePath, getIdentifierText(node.name)));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return errors;
|
|
453
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
import { createContractIssue, createVerificationIssue, getActiveVerifyRuleIds, shouldRunRule } from "./issue-helpers.mjs";
|
|
4
|
+
import { getLineAndColumn } from "./source-analysis.mjs";
|
|
5
|
+
|
|
6
|
+
const TEST_NON_DETERMINISM_PATTERNS = [
|
|
7
|
+
{ pattern: /\bDate\.now\(/, label: "Date.now()" },
|
|
8
|
+
{ pattern: /\bMath\.random\(/, label: "Math.random()" },
|
|
9
|
+
{ pattern: /\bsetTimeout\(/, label: "setTimeout()" },
|
|
10
|
+
{ pattern: /\bsetInterval\(/, label: "setInterval()" },
|
|
11
|
+
{ pattern: /\bprocess\.env\.[A-Z0-9_]+\s*=/, label: "process.env mutation" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function createIssue(contract, ruleId, relativePath, node, message, options = {}) {
|
|
15
|
+
const location = node && "getStart" in node && "getSourceFile" in node ? getLineAndColumn(node.getSourceFile(), node.getStart(node.getSourceFile())) : {};
|
|
16
|
+
return createContractIssue(contract, {
|
|
17
|
+
ruleId,
|
|
18
|
+
category: "testing",
|
|
19
|
+
relativePath,
|
|
20
|
+
line: location.line,
|
|
21
|
+
column: location.column,
|
|
22
|
+
message,
|
|
23
|
+
verificationMode: options.verificationMode,
|
|
24
|
+
confidence: options.confidence,
|
|
25
|
+
evidence: options.evidence,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasImportFrom(file, moduleName) {
|
|
30
|
+
return file.ast.statements.some(
|
|
31
|
+
(statement) => ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === moduleName,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function verifyTestingRules(_targetPath, contract, analysisContext, changedFilesContext, options = {}) {
|
|
36
|
+
const activeRuleIds = getActiveVerifyRuleIds(contract);
|
|
37
|
+
const issues = [];
|
|
38
|
+
const testFiles = analysisContext.sourceFiles.filter((file) => file.relativePath.startsWith("test/"));
|
|
39
|
+
|
|
40
|
+
for (const file of testFiles) {
|
|
41
|
+
if (activeRuleIds.has("node-test-runner-only") && shouldRunRule(options.onlyRuleIds, "node-test-runner-only") && !hasImportFrom(file, "node:test")) {
|
|
42
|
+
issues.push(createIssue(contract, "node-test-runner-only", file.relativePath, file.ast, "tests must import node:test"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
activeRuleIds.has("assert-strict-preferred") &&
|
|
47
|
+
shouldRunRule(options.onlyRuleIds, "assert-strict-preferred") &&
|
|
48
|
+
!hasImportFrom(file, "node:assert/strict")
|
|
49
|
+
) {
|
|
50
|
+
issues.push(createIssue(contract, "assert-strict-preferred", file.relativePath, file.ast, "tests must import node:assert/strict"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (activeRuleIds.has("no-ts-ignore-bypass") && shouldRunRule(options.onlyRuleIds, "no-ts-ignore-bypass")) {
|
|
54
|
+
const bypassMatch = file.raw.match(/@ts-ignore|@ts-expect-error|biome-ignore/);
|
|
55
|
+
if (bypassMatch) {
|
|
56
|
+
issues.push(createIssue(contract, "no-ts-ignore-bypass", file.relativePath, file.ast, `ignore directive is forbidden: ${bypassMatch[0]}`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (activeRuleIds.has("test-determinism-guards") && shouldRunRule(options.onlyRuleIds, "test-determinism-guards")) {
|
|
61
|
+
for (const signal of TEST_NON_DETERMINISM_PATTERNS) {
|
|
62
|
+
if (signal.pattern.test(file.raw)) {
|
|
63
|
+
issues.push(
|
|
64
|
+
createIssue(contract, "test-determinism-guards", file.relativePath, file.ast, "tests should avoid uncontrolled non-deterministic primitives", {
|
|
65
|
+
verificationMode: "heuristic",
|
|
66
|
+
confidence: "medium",
|
|
67
|
+
evidence: signal.label,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (activeRuleIds.has("test-file-naming") && shouldRunRule(options.onlyRuleIds, "test-file-naming")) {
|
|
76
|
+
for (const file of analysisContext.sourceFiles.filter((candidate) => candidate.relativePath.startsWith("test/"))) {
|
|
77
|
+
if (!file.relativePath.endsWith(".test.ts")) {
|
|
78
|
+
issues.push(createIssue(contract, "test-file-naming", file.relativePath, file.ast, "test files must use the .test.ts suffix"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (activeRuleIds.has("behavior-change-tests") && shouldRunRule(options.onlyRuleIds, "behavior-change-tests")) {
|
|
84
|
+
issues.push(...verifyBehaviorChangeTests(contract, changedFilesContext));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return issues;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function verifyBehaviorChangeTests(_contract, changedFilesContext) {
|
|
91
|
+
if (!changedFilesContext.available) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const changedSourceFiles = changedFilesContext.files.filter((file) => file.startsWith("src/") && file.endsWith(".ts"));
|
|
96
|
+
const changedTestFiles = changedFilesContext.files.filter((file) => file.startsWith("test/") && file.endsWith(".ts"));
|
|
97
|
+
|
|
98
|
+
if (changedSourceFiles.length > 0 && changedTestFiles.length === 0) {
|
|
99
|
+
return [
|
|
100
|
+
createVerificationIssue({
|
|
101
|
+
ruleId: "behavior-change-tests",
|
|
102
|
+
category: "testing",
|
|
103
|
+
severity: "audit",
|
|
104
|
+
message: "source changes require at least one changed test file",
|
|
105
|
+
verificationMode: "audit",
|
|
106
|
+
confidence: "medium",
|
|
107
|
+
evidence: `${changedSourceFiles.length} src file(s) changed, 0 test file(s) changed`,
|
|
108
|
+
}),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { createContractIssue, getActiveVerifyRuleIds, shouldRunRule } from "./issue-helpers.mjs";
|
|
5
|
+
|
|
6
|
+
const REQUIRED_SCRIPT_NAMES = ["standards:check", "check", "fix", "lint", "format:check", "typecheck", "test"];
|
|
7
|
+
|
|
8
|
+
function createIssue(contract, ruleId, relativePath, message, options = {}) {
|
|
9
|
+
return createContractIssue(contract, {
|
|
10
|
+
ruleId,
|
|
11
|
+
category: "tooling",
|
|
12
|
+
relativePath,
|
|
13
|
+
message,
|
|
14
|
+
verificationMode: options.verificationMode,
|
|
15
|
+
confidence: options.confidence,
|
|
16
|
+
evidence: options.evidence,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function verifyTooling(targetPath, contract, packageJson, options = {}) {
|
|
21
|
+
const activeRuleIds = getActiveVerifyRuleIds(contract);
|
|
22
|
+
const issues = [];
|
|
23
|
+
const scripts = packageJson.scripts ?? {};
|
|
24
|
+
const template = contract.project.template;
|
|
25
|
+
|
|
26
|
+
if (activeRuleIds.has("required-scripts") && shouldRunRule(options.onlyRuleIds, "required-scripts")) {
|
|
27
|
+
for (const scriptName of REQUIRED_SCRIPT_NAMES) {
|
|
28
|
+
if (typeof scripts[scriptName] !== "string" || scripts[scriptName].trim().length === 0) {
|
|
29
|
+
issues.push(createIssue(contract, "required-scripts", "package.json", `missing required script: ${scriptName}`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (activeRuleIds.has("standards-check-script") && shouldRunRule(options.onlyRuleIds, "standards-check-script")) {
|
|
35
|
+
if (typeof scripts["standards:check"] !== "string" || !scripts["standards:check"].includes("code-standards verify")) {
|
|
36
|
+
issues.push(createIssue(contract, "standards-check-script", "package.json", "standards:check must execute code-standards verify"));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (activeRuleIds.has("package-exports-alignment") && shouldRunRule(options.onlyRuleIds, "package-exports-alignment")) {
|
|
41
|
+
const tsconfigJson = await readOptionalJson(path.join(targetPath, "tsconfig.json"));
|
|
42
|
+
const expectedTsconfig = template === "node-service" ? "@sha3/code/tsconfig/node-service.json" : "@sha3/code/tsconfig/node-lib.json";
|
|
43
|
+
|
|
44
|
+
if (tsconfigJson.extends !== expectedTsconfig) {
|
|
45
|
+
issues.push(createIssue(contract, "package-exports-alignment", "tsconfig.json", `tsconfig.json must extend ${expectedTsconfig}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const biomeJson = await readOptionalJson(path.join(targetPath, "biome.json"));
|
|
49
|
+
if (!isExpectedBiomeConfig(biomeJson)) {
|
|
50
|
+
issues.push(createIssue(contract, "package-exports-alignment", "biome.json", "biome.json must stay aligned with the exported @sha3/code biome baseline"));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return issues;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readOptionalJson(absolutePath) {
|
|
58
|
+
const raw = await readFile(absolutePath, "utf8").catch(() => null);
|
|
59
|
+
if (!raw) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isExpectedBiomeConfig(biomeJson) {
|
|
71
|
+
return (
|
|
72
|
+
biomeJson?.formatter?.lineWidth === 160 &&
|
|
73
|
+
biomeJson?.javascript?.formatter?.quoteStyle === "double" &&
|
|
74
|
+
biomeJson?.linter?.rules?.correctness?.noUnusedVariables === "error" &&
|
|
75
|
+
biomeJson?.linter?.rules?.correctness?.noUnusedFunctionParameters === "error" &&
|
|
76
|
+
biomeJson?.linter?.rules?.correctness?.noUnusedPrivateClassMembers === "error" &&
|
|
77
|
+
biomeJson?.linter?.rules?.correctness?.noUnusedImports === "error" &&
|
|
78
|
+
Array.isArray(biomeJson?.files?.ignore) &&
|
|
79
|
+
biomeJson.files.ignore.includes(".code-standards") &&
|
|
80
|
+
biomeJson.files.ignore.includes("dist")
|
|
81
|
+
);
|
|
82
|
+
}
|