-
Notifications
You must be signed in to change notification settings - Fork 2.6k
test(cli): enforce initial layer import boundaries #2986
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4b7a4a2
2ce87dd
4cd3cf3
e9dd46e
aab6c86
8908521
56e4f05
8bf1958
38eb84d
b2ad5da
edd2650
a15da95
4f57ebb
8b2d077
a09cc51
75857dc
11c0676
05f9eca
4ebeae4
7d72437
0ee5ca5
928219c
36cce5e
702afb1
3224350
cd4cdb8
9499ca6
42028ef
a05c6b3
1875fe9
36f1dbe
78bfd5a
96ce61f
266ebc9
dc98994
d0d2a70
8396c06
1532b19
7c0445a
3978c32
591eabb
8a6ecbc
e5aa22f
e179336
a629813
c5954e7
3380de9
f60a190
78a16d1
a58dc90
034454f
28bc497
e9ed6ba
00b288c
7bd5f32
d4b55d9
b872474
4965762
9acd660
cc0eaa1
847e115
b6c0f5a
5c98bd1
4754f3e
96f0758
b6bd105
98fb75d
5920d5b
64d1646
717c482
688074f
1314270
79d81c2
5453a89
bbc297f
25224c1
1ed9256
51799e4
0b1e877
5bc2242
073225e
f1e6725
3b6e5d2
b0b5f3a
f9e6ff6
b85451a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,308 @@ | ||||||||||||||||||||||||||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||||||||||||||||||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | ||||||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||||||
| import { fileURLToPath } from "node:url"; | ||||||||||||||||||||||||||
| import ts from "typescript"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type Violation = { | ||||||||||||||||||||||||||
| file: string; | ||||||||||||||||||||||||||
| line: number; | ||||||||||||||||||||||||||
| column: number; | ||||||||||||||||||||||||||
| rule: string; | ||||||||||||||||||||||||||
| detail: string; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type ImportRef = { | ||||||||||||||||||||||||||
| specifier: string; | ||||||||||||||||||||||||||
| line: number; | ||||||||||||||||||||||||||
| column: number; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||||||||||||||||||||||||||
| const SRC_LIB_ROOT = path.join(REPO_ROOT, "src", "lib"); | ||||||||||||||||||||||||||
| const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function toRepoPath(absPath: string): string { | ||||||||||||||||||||||||||
| return path.relative(REPO_ROOT, absPath).split(path.sep).join("/"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isProductionTsFile(absPath: string): boolean { | ||||||||||||||||||||||||||
| return absPath.endsWith(".ts") && !absPath.endsWith(".test.ts") && !absPath.endsWith(".spec.ts"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function* walk(dir: string): Generator<string> { | ||||||||||||||||||||||||||
| if (!existsSync(dir)) return; | ||||||||||||||||||||||||||
| for (const entry of readdirSync(dir)) { | ||||||||||||||||||||||||||
| if (SKIP_DIRS.has(entry)) continue; | ||||||||||||||||||||||||||
| const absPath = path.join(dir, entry); | ||||||||||||||||||||||||||
| const stats = statSync(absPath); | ||||||||||||||||||||||||||
| if (stats.isDirectory()) { | ||||||||||||||||||||||||||
| yield* walk(absPath); | ||||||||||||||||||||||||||
| } else if (stats.isFile() && isProductionTsFile(absPath)) { | ||||||||||||||||||||||||||
| yield absPath; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function sourceFileFor(absPath: string): ts.SourceFile { | ||||||||||||||||||||||||||
| return ts.createSourceFile( | ||||||||||||||||||||||||||
| absPath, | ||||||||||||||||||||||||||
| readFileSync(absPath, "utf8"), | ||||||||||||||||||||||||||
| ts.ScriptTarget.Latest, | ||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||
| ts.ScriptKind.TS, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function position(sourceFile: ts.SourceFile, node: ts.Node): { line: number; column: number } { | ||||||||||||||||||||||||||
| const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); | ||||||||||||||||||||||||||
| return { line: pos.line + 1, column: pos.character + 1 }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function collectImportRefs(absPath: string): ImportRef[] { | ||||||||||||||||||||||||||
| const sourceFile = sourceFileFor(absPath); | ||||||||||||||||||||||||||
| const refs: ImportRef[] = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function add(specifier: string, node: ts.Node): void { | ||||||||||||||||||||||||||
| const pos = position(sourceFile, node); | ||||||||||||||||||||||||||
| refs.push({ specifier, ...pos }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function visit(node: ts.Node): void { | ||||||||||||||||||||||||||
| if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { | ||||||||||||||||||||||||||
| add(node.moduleSpecifier.text, node.moduleSpecifier); | ||||||||||||||||||||||||||
| } else if ( | ||||||||||||||||||||||||||
| ts.isExportDeclaration(node) && | ||||||||||||||||||||||||||
| node.moduleSpecifier && | ||||||||||||||||||||||||||
| ts.isStringLiteral(node.moduleSpecifier) | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| add(node.moduleSpecifier.text, node.moduleSpecifier); | ||||||||||||||||||||||||||
| } else if ( | ||||||||||||||||||||||||||
| ts.isCallExpression(node) && | ||||||||||||||||||||||||||
| ((ts.isIdentifier(node.expression) && node.expression.text === "require") || | ||||||||||||||||||||||||||
| node.expression.kind === ts.SyntaxKind.ImportKeyword) && | ||||||||||||||||||||||||||
| node.arguments.length > 0 && | ||||||||||||||||||||||||||
| ts.isStringLiteralLike(node.arguments[0]) | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| add(node.arguments[0].text, node.arguments[0]); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+73
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's check if the file exists and see its overall structure
fd scripts/check-layer-import-boundaries.tsRepository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Let's also get the line count to understand file size
wc -l scripts/check-layer-import-boundaries.tsRepository: NVIDIA/NemoClaw Length of output: 104 🏁 Script executed: # Now let's examine the collectImportRefs function around lines 73-90
sed -n '60,100p' scripts/check-layer-import-boundaries.tsRepository: NVIDIA/NemoClaw Length of output: 1499 🏁 Script executed: # Let's also check if ImportEqualsDeclaration is mentioned anywhere in the file
rg "ImportEqualsDeclaration" scripts/check-layer-import-boundaries.tsRepository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Check if there's any configuration (tsconfig.json, biome.json) that might restrict import forms
fd -e json -e js | grep -E "(biome|tsconfig)" | head -20Repository: NVIDIA/NemoClaw Length of output: 162 🏁 Script executed: # Check the biome.json configuration
cat biome.jsonRepository: NVIDIA/NemoClaw Length of output: 3139 🏁 Script executed: # Check tsconfig files to see if there are any restrictions
cat tsconfig.cli.jsonRepository: NVIDIA/NemoClaw Length of output: 628 🏁 Script executed: # Search the codebase for any use of "import ... = require"
rg "import\s+\w+\s*=\s*require" --type ts --type jsRepository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Check if there are any tests for the check-layer-import-boundaries script
fd check-layer-import-boundariesRepository: NVIDIA/NemoClaw Length of output: 100 🏁 Script executed: # Let's also check if this import form is used in the src directory at all
rg "import\s+\w+\s*=\s*require" src/ --type ts --type jsRepository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Let's check the SPDX header compliance for this file
head -5 scripts/check-layer-import-boundaries.tsRepository: NVIDIA/NemoClaw Length of output: 304 🏁 Script executed: # Let's verify the suggested diff is syntactically correct by checking TypeScript AST types
# Create a simple test to check if ImportEqualsDeclaration and ExternalModuleReference exist
python3 << 'EOF'
# The suggestion uses:
# ts.isImportEqualsDeclaration(node)
# ts.isExternalModuleReference(node.moduleReference)
# node.moduleReference.expression
# ts.isStringLiteralLike(node.moduleReference.expression)
# These are legitimate TypeScript compiler API methods/properties
# Let's verify by searching for any existing usage patterns in TypeScript
print("Verifying TypeScript AST API for ImportEqualsDeclaration...")
print("The following are valid TypeScript compiler API:")
print("- ts.isImportEqualsDeclaration(node): Checks if node is 'import x = require(...)'")
print("- ts.isExternalModuleReference(): Checks if moduleReference is external")
print("- ts.isStringLiteralLike(): Matches string literals and template literals")
print("\nSuggested fix appears syntactically correct for TypeScript Compiler API.")
EOFRepository: NVIDIA/NemoClaw Length of output: 470 🏁 Script executed: # Let's check if there are any tests or comments in the file that mention ImportEqualsDeclaration
cat -n scripts/check-layer-import-boundaries.ts | grep -A 5 -B 5 -i "importequals\|import.*require"Repository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Check what import forms are actually used in src/lib (the directory being checked)
rg "^import\s" src/lib --type ts | head -20Repository: NVIDIA/NemoClaw Length of output: 1611 🏁 Script executed: # Also check for any require patterns in src/lib
rg "require\(" src/lib --type ts | head -20Repository: NVIDIA/NemoClaw Length of output: 1538 Handle
Suggested change function visit(node: ts.Node): void {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
add(node.moduleSpecifier.text, node.moduleSpecifier);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
add(node.moduleSpecifier.text, node.moduleSpecifier);
+ } else if (
+ ts.isImportEqualsDeclaration(node) &&
+ ts.isExternalModuleReference(node.moduleReference) &&
+ node.moduleReference.expression &&
+ ts.isStringLiteralLike(node.moduleReference.expression)
+ ) {
+ add(node.moduleReference.expression.text, node.moduleReference.expression);
} else if (
ts.isCallExpression(node) &&
((ts.isIdentifier(node.expression) && node.expression.text === "require") ||
node.expression.kind === ts.SyntaxKind.ImportKeyword) &&
node.arguments.length > 0 &&🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| ts.forEachChild(node, visit); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| visit(sourceFile); | ||||||||||||||||||||||||||
| return refs; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function resolveInternalImport(fromAbsPath: string, specifier: string): string | null { | ||||||||||||||||||||||||||
| if (!specifier.startsWith(".")) return null; | ||||||||||||||||||||||||||
| const base = path.resolve(path.dirname(fromAbsPath), specifier); | ||||||||||||||||||||||||||
| const candidates = [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts")]; | ||||||||||||||||||||||||||
| const found = candidates.find((candidate) => existsSync(candidate)); | ||||||||||||||||||||||||||
| return found ? toRepoPath(found) : toRepoPath(`${base}.ts`); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isDomainFile(repoPath: string): boolean { | ||||||||||||||||||||||||||
| return repoPath.startsWith("src/lib/domain/"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isAdapterFile(repoPath: string): boolean { | ||||||||||||||||||||||||||
| return repoPath.startsWith("src/lib/adapters/"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isCommandFile(repoPath: string): boolean { | ||||||||||||||||||||||||||
| return repoPath.startsWith("src/lib/commands/"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isActionFile(repoPath: string): boolean { | ||||||||||||||||||||||||||
| if (repoPath.startsWith("src/lib/actions/")) return true; | ||||||||||||||||||||||||||
| return /(^|\/)[^/]+-actions?\.ts$/.test(repoPath); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isGeneratedOrRegistryCommandFile(repoPath: string): boolean { | ||||||||||||||||||||||||||
| return repoPath === "src/lib/commands/index.ts" || repoPath.endsWith("/common.ts"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function importTargetsForbiddenLayer( | ||||||||||||||||||||||||||
| fromAbsPath: string, | ||||||||||||||||||||||||||
| ref: ImportRef, | ||||||||||||||||||||||||||
| forbiddenPrefixes: readonly string[], | ||||||||||||||||||||||||||
| forbiddenActionFiles = false, | ||||||||||||||||||||||||||
| ): string | null { | ||||||||||||||||||||||||||
| const target = resolveInternalImport(fromAbsPath, ref.specifier); | ||||||||||||||||||||||||||
| if (!target) return null; | ||||||||||||||||||||||||||
| if (forbiddenPrefixes.some((prefix) => target.startsWith(prefix))) return target; | ||||||||||||||||||||||||||
| if (forbiddenActionFiles && isActionFile(target)) return target; | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function addViolation( | ||||||||||||||||||||||||||
| violations: Violation[], | ||||||||||||||||||||||||||
| file: string, | ||||||||||||||||||||||||||
| line: number, | ||||||||||||||||||||||||||
| column: number, | ||||||||||||||||||||||||||
| rule: string, | ||||||||||||||||||||||||||
| detail: string, | ||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||
| violations.push({ file, line, column, rule, detail }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function checkDomainFile(absPath: string, repoPath: string, violations: Violation[]): void { | ||||||||||||||||||||||||||
| const imports = collectImportRefs(absPath); | ||||||||||||||||||||||||||
| for (const ref of imports) { | ||||||||||||||||||||||||||
| if (ref.specifier === "@oclif/core") { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| ref.line, | ||||||||||||||||||||||||||
| ref.column, | ||||||||||||||||||||||||||
| "domain-purity", | ||||||||||||||||||||||||||
| "domain must not import @oclif/core", | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if (ref.specifier === "node:child_process" || ref.specifier === "child_process") { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| ref.line, | ||||||||||||||||||||||||||
| ref.column, | ||||||||||||||||||||||||||
| "domain-purity", | ||||||||||||||||||||||||||
| "domain must not spawn child processes", | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const target = importTargetsForbiddenLayer( | ||||||||||||||||||||||||||
| absPath, | ||||||||||||||||||||||||||
| ref, | ||||||||||||||||||||||||||
| ["src/lib/adapters/", "src/lib/commands/", "src/lib/cli/"], | ||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| if (target) { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| ref.line, | ||||||||||||||||||||||||||
| ref.column, | ||||||||||||||||||||||||||
| "domain-purity", | ||||||||||||||||||||||||||
| `domain must not import ${target}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const sourceFile = sourceFileFor(absPath); | ||||||||||||||||||||||||||
| function visit(node: ts.Node): void { | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| ts.isPropertyAccessExpression(node) && | ||||||||||||||||||||||||||
| ts.isIdentifier(node.expression) && | ||||||||||||||||||||||||||
| node.expression.text === "process" && | ||||||||||||||||||||||||||
| node.name.text === "exit" | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| const pos = position(sourceFile, node); | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| pos.line, | ||||||||||||||||||||||||||
| pos.column, | ||||||||||||||||||||||||||
| "domain-purity", | ||||||||||||||||||||||||||
| "domain must not call process.exit", | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ts.forEachChild(node, visit); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| visit(sourceFile); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function checkActionFile(absPath: string, repoPath: string, violations: Violation[]): void { | ||||||||||||||||||||||||||
| for (const ref of collectImportRefs(absPath)) { | ||||||||||||||||||||||||||
| if (ref.specifier === "@oclif/core") { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| ref.line, | ||||||||||||||||||||||||||
| ref.column, | ||||||||||||||||||||||||||
| "actions-no-oclif", | ||||||||||||||||||||||||||
| "actions must not import @oclif/core", | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function checkAdapterFile(absPath: string, repoPath: string, violations: Violation[]): void { | ||||||||||||||||||||||||||
| for (const ref of collectImportRefs(absPath)) { | ||||||||||||||||||||||||||
| const target = importTargetsForbiddenLayer(absPath, ref, ["src/lib/commands/"], true); | ||||||||||||||||||||||||||
| if (target) { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| ref.line, | ||||||||||||||||||||||||||
| ref.column, | ||||||||||||||||||||||||||
| "adapters-no-workflows", | ||||||||||||||||||||||||||
| `adapters must not import command/action layer module ${target}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function checkCommandFile(absPath: string, repoPath: string, violations: Violation[]): void { | ||||||||||||||||||||||||||
| if (isGeneratedOrRegistryCommandFile(repoPath)) return; | ||||||||||||||||||||||||||
| const sourceFile = sourceFileFor(absPath); | ||||||||||||||||||||||||||
| let commandClassCount = 0; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isCommandBase(expression: ts.ExpressionWithTypeArguments): boolean { | ||||||||||||||||||||||||||
| const text = expression.expression.getText(sourceFile); | ||||||||||||||||||||||||||
| return text === "Command" || text === "NemoClawCommand"; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function visit(node: ts.Node): void { | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| ts.isClassDeclaration(node) && | ||||||||||||||||||||||||||
| node.heritageClauses?.some((clause) => clause.types.some(isCommandBase)) | ||||||||||||||||||||||||||
|
Comment on lines
+257
to
+259
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n scripts/check-layer-import-boundaries.ts | sed -n '250,270p'Repository: NVIDIA/NemoClaw Length of output: 767 🏁 Script executed: cat -n scripts/check-layer-import-boundaries.ts | sed -n '240,280p'Repository: NVIDIA/NemoClaw Length of output: 1521 🏁 Script executed: # Let's verify the TypeScript API for heritage clauses
# Check if there are examples of ts.SyntaxKind.ExtendsKeyword usage in the codebase
rg "SyntaxKind\." scripts/ -A 2 -B 2 | head -50Repository: NVIDIA/NemoClaw Length of output: 3099 🏁 Script executed: # Search for heritage clause usage in TypeScript API
rg "heritageClauses" scripts/ -B 3 -A 3Repository: NVIDIA/NemoClaw Length of output: 555 🌐 Web query:
💡 Result: Answer: In the TypeScript compiler API, the “extends”/“implements” keyword in a heritage clause is represented by the HeritageClause node’s Citations:
🏁 Script executed: # Look for tests related to checkCommandFile or one-command-per-file
find . -name "*.test.*" -o -name "*.spec.*" | xargs grep -l "one-command-per-file\|checkCommandFile" 2>/dev/nullRepository: NVIDIA/NemoClaw Length of output: 43 🏁 Script executed: # Search for test files related to check-layer-import-boundaries
fd -e test.ts -e spec.ts | xargs grep -l "check-layer-import-boundaries\|layer.*import" 2>/dev/null | head -5Repository: NVIDIA/NemoClaw Length of output: 96 🏁 Script executed: cat -n test/layer-import-boundaries.test.ts | head -100Repository: NVIDIA/NemoClaw Length of output: 1075 🏁 Script executed: # Search for actual command files that might inherit from Command or NemoClawCommand
find . -path ./node_modules -prune -o -name "*.ts" -type f -print | xargs grep -l "extends Command\|extends NemoClawCommand\|implements Command" 2>/dev/null | head -5Repository: NVIDIA/NemoClaw Length of output: 247 🏁 Script executed: # Check one of the command files to see the pattern
cat -n ./src/lib/commands/credentials.ts | head -30Repository: NVIDIA/NemoClaw Length of output: 1136 🏁 Script executed: # Search for implements Command (case-insensitive)
rg "implements\s+.*Command" --type ts | head -20Repository: NVIDIA/NemoClaw Length of output: 41 🏁 Script executed: # Let's look for any helper classes or interfaces that might implement or extend Command
rg "class.*implements.*|interface.*extends.*" --type ts | grep -i command | head -20Repository: NVIDIA/NemoClaw Length of output: 172 🏁 Script executed: # Let's verify the TypeScript API once more by checking if there's a ts.HeritageClause type definition being used
rg "HeritageClause" --type ts | head -10Repository: NVIDIA/NemoClaw Length of output: 41 Only count The current predicate treats Suggested change if (
ts.isClassDeclaration(node) &&
- node.heritageClauses?.some((clause) => clause.types.some(isCommandBase))
+ node.heritageClauses?.some(
+ (clause) =>
+ clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.some(isCommandBase),
+ )
) {
commandClassCount += 1;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| commandClassCount += 1; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ts.forEachChild(node, visit); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| visit(sourceFile); | ||||||||||||||||||||||||||
| if (commandClassCount !== 1) { | ||||||||||||||||||||||||||
| addViolation( | ||||||||||||||||||||||||||
| violations, | ||||||||||||||||||||||||||
| repoPath, | ||||||||||||||||||||||||||
| 1, | ||||||||||||||||||||||||||
| 1, | ||||||||||||||||||||||||||
| "one-command-per-file", | ||||||||||||||||||||||||||
| `command files must define exactly one registered oclif command class; found ${commandClassCount}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function findLayerImportBoundaryViolations(root = SRC_LIB_ROOT): Violation[] { | ||||||||||||||||||||||||||
| const violations: Violation[] = []; | ||||||||||||||||||||||||||
| for (const absPath of walk(root)) { | ||||||||||||||||||||||||||
| const repoPath = toRepoPath(absPath); | ||||||||||||||||||||||||||
| if (isDomainFile(repoPath)) checkDomainFile(absPath, repoPath, violations); | ||||||||||||||||||||||||||
| if (isActionFile(repoPath)) checkActionFile(absPath, repoPath, violations); | ||||||||||||||||||||||||||
| if (isAdapterFile(repoPath)) checkAdapterFile(absPath, repoPath, violations); | ||||||||||||||||||||||||||
| if (isCommandFile(repoPath)) checkCommandFile(absPath, repoPath, violations); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return violations; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function main(): void { | ||||||||||||||||||||||||||
| const violations = findLayerImportBoundaryViolations(); | ||||||||||||||||||||||||||
| if (violations.length > 0) { | ||||||||||||||||||||||||||
| const formatted = violations | ||||||||||||||||||||||||||
| .map( | ||||||||||||||||||||||||||
| (violation) => | ||||||||||||||||||||||||||
| `${violation.file}:${String(violation.line)}:${String(violation.column)} ${violation.rule}: ${violation.detail}`, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| .join("\n"); | ||||||||||||||||||||||||||
| console.error(`Layer import boundary violations:\n${formatted}`); | ||||||||||||||||||||||||||
| process.exit(1); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| console.log("Layer import boundaries passed."); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { | ||||||||||||||||||||||||||
| main(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { spawnSync } from "node:child_process"; | ||
| import path from "node:path"; | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| const REPO_ROOT = path.join(import.meta.dirname, ".."); | ||
| const TSX = path.join(REPO_ROOT, "node_modules", ".bin", "tsx"); | ||
| const BOUNDARY_SCRIPT = path.join(REPO_ROOT, "scripts", "check-layer-import-boundaries.ts"); | ||
|
|
||
| describe("CLI layer import boundaries", () => { | ||
| it("keeps domain, adapter, action, and command layers separated", () => { | ||
| const result = spawnSync(TSX, [BOUNDARY_SCRIPT], { | ||
| cwd: REPO_ROOT, | ||
| encoding: "utf-8", | ||
| }); | ||
|
|
||
| expect(`${result.stdout}${result.stderr}`).toContain("Layer import boundaries passed."); | ||
| expect(result.status).toBe(0); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.