@agjs/tsforge 0.1.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/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
package/src/lsp/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface ITsDiagnostic {
|
|
4
|
+
code: number;
|
|
5
|
+
message: string;
|
|
6
|
+
file: string;
|
|
7
|
+
start: number;
|
|
8
|
+
length: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ITsFix {
|
|
12
|
+
description: string;
|
|
13
|
+
/** The text edits this fix applies (possibly across files). */
|
|
14
|
+
changes: readonly ts.FileTextChanges[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A reference / definition location, with a 1-based line for readable output. */
|
|
18
|
+
export interface ITsLocation {
|
|
19
|
+
file: string;
|
|
20
|
+
line: number;
|
|
21
|
+
start: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A workspace symbol hit (from navigate-to). */
|
|
25
|
+
export interface ITsSymbol {
|
|
26
|
+
name: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reference sites in one file (the lines that depend on a symbol). */
|
|
33
|
+
export interface ITsImpactFile {
|
|
34
|
+
file: string;
|
|
35
|
+
lines: number[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The blast radius of a symbol: which files/lines reference it. Type-EXACT (from
|
|
39
|
+
* the TS LanguageService), not a tree-sitter guess — so it can drive a deliberate
|
|
40
|
+
* cross-file edit instead of discovering breakage at the gate. */
|
|
41
|
+
export interface ITsImpact {
|
|
42
|
+
/** Total reference sites (excluding the declaration itself). */
|
|
43
|
+
total: number;
|
|
44
|
+
/** Number of distinct files that reference the symbol. */
|
|
45
|
+
fileCount: number;
|
|
46
|
+
/** Per-file reference lines, most-referenced file first. */
|
|
47
|
+
files: ITsImpactFile[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A 360° view of a symbol: its type, where it's defined, and what references it. */
|
|
51
|
+
export interface ITsContext {
|
|
52
|
+
/** Quick-info type string (what `typeAt` returns). */
|
|
53
|
+
type: string;
|
|
54
|
+
definition: ITsLocation[];
|
|
55
|
+
references: ITsLocation[];
|
|
56
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { join, isAbsolute } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type {
|
|
4
|
+
ITsDiagnostic,
|
|
5
|
+
ITsFix,
|
|
6
|
+
ITsLocation,
|
|
7
|
+
ITsSymbol,
|
|
8
|
+
ITsImpact,
|
|
9
|
+
ITsContext,
|
|
10
|
+
} from "./lsp.types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Quick-fixes safe to apply automatically — they align with our strict gate.
|
|
14
|
+
* Deliberately EXCLUDES `addNonNullAssertion` and anything inserting `as`/`!`,
|
|
15
|
+
* which the constitution bans. The gate re-validates regardless, so a bad fix
|
|
16
|
+
* can't ship, but we don't want the harness fighting its own rules.
|
|
17
|
+
*/
|
|
18
|
+
const SAFE_FIXES = new Set([
|
|
19
|
+
"import",
|
|
20
|
+
"fixMissingImport",
|
|
21
|
+
"unusedIdentifier",
|
|
22
|
+
"addMissingAwait",
|
|
23
|
+
"fixOverrideModifier",
|
|
24
|
+
"fixMissingMember",
|
|
25
|
+
"fixMissingProperties",
|
|
26
|
+
"fixUnreachableCode",
|
|
27
|
+
"fixAddMissingConstraint",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thin wrapper over the TypeScript LanguageService — the engine behind tsserver,
|
|
32
|
+
* run in-process. Gives semantic diagnostics, TypeScript's own quick-fixes,
|
|
33
|
+
* semantic rename, and type-at-cursor, scoped to one project's `tsconfig`. The
|
|
34
|
+
* `tsc -p` gate stays the authority; this is for fixes / speed / semantics.
|
|
35
|
+
*/
|
|
36
|
+
export class TsService {
|
|
37
|
+
private readonly service: ts.LanguageService;
|
|
38
|
+
private readonly versions = new Map<string, number>();
|
|
39
|
+
private readonly files: string[];
|
|
40
|
+
|
|
41
|
+
constructor(private readonly dir: string) {
|
|
42
|
+
const configPath = join(dir, "tsconfig.json");
|
|
43
|
+
const read = ts.readConfigFile(configPath, (p) => ts.sys.readFile(p));
|
|
44
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
45
|
+
read.config ?? {},
|
|
46
|
+
ts.sys,
|
|
47
|
+
dir
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
this.files = [...parsed.fileNames];
|
|
51
|
+
|
|
52
|
+
const host: ts.LanguageServiceHost = {
|
|
53
|
+
getScriptFileNames: () => this.files,
|
|
54
|
+
getScriptVersion: (f) => String(this.versions.get(f) ?? 0),
|
|
55
|
+
getScriptSnapshot: (f) => {
|
|
56
|
+
const text = ts.sys.readFile(f);
|
|
57
|
+
|
|
58
|
+
return text === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: ts.ScriptSnapshot.fromString(text);
|
|
61
|
+
},
|
|
62
|
+
getCurrentDirectory: () => dir,
|
|
63
|
+
getCompilationSettings: () => parsed.options,
|
|
64
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
65
|
+
fileExists: (p) => ts.sys.fileExists(p),
|
|
66
|
+
readFile: (p) => ts.sys.readFile(p),
|
|
67
|
+
readDirectory: (p, ext, exclude, include, depth) =>
|
|
68
|
+
ts.sys.readDirectory(p, ext, exclude, include, depth),
|
|
69
|
+
directoryExists: (p) => ts.sys.directoryExists(p),
|
|
70
|
+
getDirectories: (p) => ts.sys.getDirectories(p),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
this.service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Re-read a file the harness changed on disk (bump its version, track new files). */
|
|
77
|
+
refresh(file: string): void {
|
|
78
|
+
const abs = this.toAbs(file);
|
|
79
|
+
|
|
80
|
+
this.versions.set(abs, (this.versions.get(abs) ?? 0) + 1);
|
|
81
|
+
|
|
82
|
+
if (!this.files.includes(abs)) {
|
|
83
|
+
this.files.push(abs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
diagnostics(file: string): ITsDiagnostic[] {
|
|
88
|
+
const abs = this.toAbs(file);
|
|
89
|
+
const raw = [
|
|
90
|
+
...this.service.getSyntacticDiagnostics(abs),
|
|
91
|
+
...this.service.getSemanticDiagnostics(abs),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const out: ITsDiagnostic[] = [];
|
|
95
|
+
|
|
96
|
+
for (const d of raw) {
|
|
97
|
+
if (d.start === undefined || d.length === undefined) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
out.push({
|
|
102
|
+
code: d.code,
|
|
103
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
|
|
104
|
+
file: abs,
|
|
105
|
+
start: d.start,
|
|
106
|
+
length: d.length,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
quickFixes(file: string): ITsFix[] {
|
|
114
|
+
const abs = this.toAbs(file);
|
|
115
|
+
const prefs: ts.UserPreferences = {};
|
|
116
|
+
const fmt = ts.getDefaultFormatCodeSettings("\n");
|
|
117
|
+
const fixes: ITsFix[] = [];
|
|
118
|
+
|
|
119
|
+
for (const d of this.diagnostics(file)) {
|
|
120
|
+
const actions = this.service.getCodeFixesAtPosition(
|
|
121
|
+
abs,
|
|
122
|
+
d.start,
|
|
123
|
+
d.start + d.length,
|
|
124
|
+
[d.code],
|
|
125
|
+
fmt,
|
|
126
|
+
prefs
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
for (const a of actions) {
|
|
130
|
+
fixes.push({ description: a.description, changes: a.changes });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return fixes;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Apply TypeScript's own safe quick-fixes to `file` until none remain — the
|
|
139
|
+
* deterministic "eslint --fix, but for TS" step. Returns how many were applied.
|
|
140
|
+
* Iterative (apply one, re-ground) so cascading fixes resolve cleanly.
|
|
141
|
+
*/
|
|
142
|
+
fixAll(file: string, maxPasses = 20): number {
|
|
143
|
+
let applied = 0;
|
|
144
|
+
|
|
145
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
146
|
+
const fix = this.firstSafeFix(file);
|
|
147
|
+
|
|
148
|
+
if (fix === undefined) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.applyChanges(fix.changes);
|
|
153
|
+
applied += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return applied;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private firstSafeFix(file: string): ts.CodeFixAction | undefined {
|
|
160
|
+
const abs = this.toAbs(file);
|
|
161
|
+
const fmt = ts.getDefaultFormatCodeSettings("\n");
|
|
162
|
+
|
|
163
|
+
for (const d of this.diagnostics(file)) {
|
|
164
|
+
const actions = this.service.getCodeFixesAtPosition(
|
|
165
|
+
abs,
|
|
166
|
+
d.start,
|
|
167
|
+
d.start + d.length,
|
|
168
|
+
[d.code],
|
|
169
|
+
fmt,
|
|
170
|
+
{}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
for (const a of actions) {
|
|
174
|
+
if (SAFE_FIXES.has(a.fixName)) {
|
|
175
|
+
return a;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private applyChanges(changes: readonly ts.FileTextChanges[]): void {
|
|
184
|
+
for (const fc of changes) {
|
|
185
|
+
const original = ts.sys.readFile(fc.fileName);
|
|
186
|
+
|
|
187
|
+
if (original === undefined) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Apply edits back-to-front so earlier spans keep their offsets.
|
|
192
|
+
const sorted = [...fc.textChanges].sort(
|
|
193
|
+
(a, b) => b.span.start - a.span.start
|
|
194
|
+
);
|
|
195
|
+
let text = original;
|
|
196
|
+
|
|
197
|
+
for (const tc of sorted) {
|
|
198
|
+
text =
|
|
199
|
+
text.slice(0, tc.span.start) +
|
|
200
|
+
tc.newText +
|
|
201
|
+
text.slice(tc.span.start + tc.span.length);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
ts.sys.writeFile(fc.fileName, text);
|
|
205
|
+
this.refresh(fc.fileName);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Find every reference to rename (Slice 3 adds the new name + applies them). */
|
|
210
|
+
renameLocations(
|
|
211
|
+
file: string,
|
|
212
|
+
position: number
|
|
213
|
+
): readonly ts.RenameLocation[] | undefined {
|
|
214
|
+
return this.service.findRenameLocations(
|
|
215
|
+
this.toAbs(file),
|
|
216
|
+
position,
|
|
217
|
+
false,
|
|
218
|
+
false,
|
|
219
|
+
{}
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
typeAt(file: string, position: number): string {
|
|
224
|
+
const info = this.service.getQuickInfoAtPosition(
|
|
225
|
+
this.toAbs(file),
|
|
226
|
+
position
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (info === undefined) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return ts.displayPartsToString(info.displayParts);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** All references to the symbol at `position` (across the project). */
|
|
237
|
+
references(file: string, position: number): ITsLocation[] {
|
|
238
|
+
const refs = this.service.getReferencesAtPosition(
|
|
239
|
+
this.toAbs(file),
|
|
240
|
+
position
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return (refs ?? []).map((r) => ({
|
|
244
|
+
file: r.fileName,
|
|
245
|
+
line: this.lineOf(r.fileName, r.textSpan.start),
|
|
246
|
+
start: r.textSpan.start,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Definition location(s) of the symbol at `position`. */
|
|
251
|
+
definition(file: string, position: number): ITsLocation[] {
|
|
252
|
+
const defs = this.service.getDefinitionAtPosition(
|
|
253
|
+
this.toAbs(file),
|
|
254
|
+
position
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return (defs ?? []).map((d) => ({
|
|
258
|
+
file: d.fileName,
|
|
259
|
+
line: this.lineOf(d.fileName, d.textSpan.start),
|
|
260
|
+
start: d.textSpan.start,
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* BLAST RADIUS of the symbol at `position`: which files/lines reference it,
|
|
266
|
+
* EXCLUDING its own declaration site. Type-exact (the TS LanguageService resolves
|
|
267
|
+
* it), so it can drive a deliberate cross-file edit — "you changed X, it's used
|
|
268
|
+
* at A:12, B:40" — instead of discovering breakage at the gate. Files are ordered
|
|
269
|
+
* most-referenced first.
|
|
270
|
+
*/
|
|
271
|
+
impact(file: string, position: number): ITsImpact {
|
|
272
|
+
const abs = this.toAbs(file);
|
|
273
|
+
const refs = this.service.getReferencesAtPosition(abs, position) ?? [];
|
|
274
|
+
const byFile = new Map<string, number[]>();
|
|
275
|
+
|
|
276
|
+
for (const r of refs) {
|
|
277
|
+
// Drop the declaration occurrence itself — blast radius is the DEPENDANTS.
|
|
278
|
+
if (r.fileName === abs && r.textSpan.start === position) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const lines = byFile.get(r.fileName) ?? [];
|
|
283
|
+
|
|
284
|
+
lines.push(this.lineOf(r.fileName, r.textSpan.start));
|
|
285
|
+
byFile.set(r.fileName, lines);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const files = [...byFile.entries()]
|
|
289
|
+
.map(([f, lines]) => ({
|
|
290
|
+
file: f,
|
|
291
|
+
lines: [...lines].sort((a, b) => a - b),
|
|
292
|
+
}))
|
|
293
|
+
.sort((a, b) => b.lines.length - a.lines.length);
|
|
294
|
+
const total = files.reduce((n, f) => n + f.lines.length, 0);
|
|
295
|
+
|
|
296
|
+
return { total, fileCount: files.length, files };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A 360° view of the symbol at `position`: its type, definition site(s), and
|
|
300
|
+
* every reference — the pieces a model needs to reason about it in one call. */
|
|
301
|
+
context(file: string, position: number): ITsContext {
|
|
302
|
+
return {
|
|
303
|
+
type: this.typeAt(file, position),
|
|
304
|
+
definition: this.definition(file, position),
|
|
305
|
+
references: this.references(file, position),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Start offsets of `file`'s top-level EXPORTED declarations (the symbols other
|
|
310
|
+
* files can depend on) — the entry points for a file-level blast-radius check. */
|
|
311
|
+
private exportedPositions(abs: string): number[] {
|
|
312
|
+
const sf = this.service.getProgram()?.getSourceFile(abs);
|
|
313
|
+
|
|
314
|
+
if (sf === undefined) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const out: number[] = [];
|
|
319
|
+
|
|
320
|
+
sf.forEachChild((node) => {
|
|
321
|
+
const mods = ts.canHaveModifiers(node)
|
|
322
|
+
? ts.getModifiers(node)
|
|
323
|
+
: undefined;
|
|
324
|
+
const exported =
|
|
325
|
+
mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
326
|
+
|
|
327
|
+
if (!exported) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (ts.isVariableStatement(node)) {
|
|
332
|
+
for (const d of node.declarationList.declarations) {
|
|
333
|
+
if (ts.isIdentifier(d.name)) {
|
|
334
|
+
out.push(d.name.getStart(sf));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (
|
|
338
|
+
(ts.isFunctionDeclaration(node) ||
|
|
339
|
+
ts.isClassDeclaration(node) ||
|
|
340
|
+
ts.isInterfaceDeclaration(node) ||
|
|
341
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
342
|
+
ts.isEnumDeclaration(node)) &&
|
|
343
|
+
node.name !== undefined
|
|
344
|
+
) {
|
|
345
|
+
out.push(node.name.getStart(sf));
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* CROSS-FILE BLAST RADIUS of an edit to `file`: the files that depend on its
|
|
354
|
+
* exports AND now have type errors — i.e. what this edit BROKE downstream. This
|
|
355
|
+
* is the write-guard extended across the import graph: change a signature and the
|
|
356
|
+
* harness can say "you also broke deals.service.ts:12" THIS turn, instead of the
|
|
357
|
+
* model discovering it at the gate. `ignoreCodes` drops expected mid-build noise
|
|
358
|
+
* (e.g. 2307 cannot-find-module). Naturally cheap for a fresh file (no dependants
|
|
359
|
+
* yet → no references → empty).
|
|
360
|
+
*/
|
|
361
|
+
dependantErrors(
|
|
362
|
+
file: string,
|
|
363
|
+
ignoreCodes: ReadonlySet<number> = new Set()
|
|
364
|
+
): { file: string; errors: ITsDiagnostic[] }[] {
|
|
365
|
+
const abs = this.toAbs(file);
|
|
366
|
+
const deps = new Set<string>();
|
|
367
|
+
|
|
368
|
+
for (const pos of this.exportedPositions(abs)) {
|
|
369
|
+
for (const f of this.impact(file, pos).files) {
|
|
370
|
+
if (f.file !== abs) {
|
|
371
|
+
deps.add(f.file);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const out: { file: string; errors: ITsDiagnostic[] }[] = [];
|
|
377
|
+
|
|
378
|
+
for (const dep of deps) {
|
|
379
|
+
this.refresh(dep);
|
|
380
|
+
|
|
381
|
+
const errors = this.diagnostics(dep).filter(
|
|
382
|
+
(d) => !ignoreCodes.has(d.code)
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (errors.length > 0) {
|
|
386
|
+
out.push({ file: dep, errors });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Workspace symbol search by name (navigate-to) — find a symbol/file without
|
|
394
|
+
* knowing the path. */
|
|
395
|
+
symbols(query: string, max = 50): ITsSymbol[] {
|
|
396
|
+
return this.service.getNavigateToItems(query, max).map((i) => ({
|
|
397
|
+
name: i.name,
|
|
398
|
+
kind: i.kind,
|
|
399
|
+
file: i.fileName,
|
|
400
|
+
line: this.lineOf(i.fileName, i.textSpan.start),
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Semantic rename across ALL references, applied to disk. Returns the number
|
|
406
|
+
* of locations changed, or null if the symbol can't be renamed. Callers must
|
|
407
|
+
* enforce scope (a rename can touch read-only files — the loop checks).
|
|
408
|
+
*/
|
|
409
|
+
rename(file: string, position: number, newName: string): number | null {
|
|
410
|
+
const locs = this.service.findRenameLocations(
|
|
411
|
+
this.toAbs(file),
|
|
412
|
+
position,
|
|
413
|
+
false,
|
|
414
|
+
false,
|
|
415
|
+
{}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (locs === undefined || locs.length === 0) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const byFile = new Map<string, ts.TextChange[]>();
|
|
423
|
+
|
|
424
|
+
for (const loc of locs) {
|
|
425
|
+
const arr = byFile.get(loc.fileName) ?? [];
|
|
426
|
+
|
|
427
|
+
arr.push({ span: loc.textSpan, newText: newName });
|
|
428
|
+
byFile.set(loc.fileName, arr);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const changes: ts.FileTextChanges[] = [...byFile].map(
|
|
432
|
+
([fileName, textChanges]) => ({ fileName, textChanges, isNewFile: false })
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
this.applyChanges(changes);
|
|
436
|
+
|
|
437
|
+
return locs.length;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Which files a rename at `position` would touch (for scope-checking BEFORE
|
|
441
|
+
* applying). Absolute paths. */
|
|
442
|
+
renameTargets(file: string, position: number): string[] {
|
|
443
|
+
const locs = this.service.findRenameLocations(
|
|
444
|
+
this.toAbs(file),
|
|
445
|
+
position,
|
|
446
|
+
false,
|
|
447
|
+
false,
|
|
448
|
+
{}
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
return [...new Set((locs ?? []).map((l) => l.fileName))];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Organize imports (dedupe/sort/drop unused) for one file. Returns edits made. */
|
|
455
|
+
organizeImports(file: string): number {
|
|
456
|
+
const changes = this.service.organizeImports(
|
|
457
|
+
{ type: "file", fileName: this.toAbs(file) },
|
|
458
|
+
ts.getDefaultFormatCodeSettings("\n"),
|
|
459
|
+
{}
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
if (changes.length === 0) {
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
this.applyChanges(changes);
|
|
467
|
+
|
|
468
|
+
return changes.reduce((n, c) => n + c.textChanges.length, 0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Ergonomic position lookup: the model addresses symbols by NAME, not byte
|
|
473
|
+
* offset. Returns the offset of the first word-boundary occurrence of `symbol`
|
|
474
|
+
* in `file`, or undefined. Crude but practical (rename re-validates via the
|
|
475
|
+
* gate; references/typeAt are read-only).
|
|
476
|
+
*/
|
|
477
|
+
positionOfSymbol(file: string, symbol: string): number | undefined {
|
|
478
|
+
const text = ts.sys.readFile(this.toAbs(file));
|
|
479
|
+
|
|
480
|
+
if (text === undefined) {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
485
|
+
const match = new RegExp(`\\b${escaped}\\b`).exec(text);
|
|
486
|
+
|
|
487
|
+
return match === null ? undefined : match.index;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 1-based line number of a byte offset in a file (for readable tool output). */
|
|
491
|
+
private lineOf(file: string, position: number): number {
|
|
492
|
+
const text = ts.sys.readFile(file) ?? "";
|
|
493
|
+
|
|
494
|
+
return text.slice(0, position).split("\n").length;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private toAbs(file: string): string {
|
|
498
|
+
return isAbsolute(file) ? file : join(this.dir, file);
|
|
499
|
+
}
|
|
500
|
+
}
|