@entrorove/skill 0.1.50 → 0.1.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +3 -1
- package/SKILL.md +1 -1
- package/ide-command.mjs +48 -16
- package/local-discipline-analysis.mjs +11 -5
- package/local-discipline-repository.mjs +7 -4
- package/local-scope-files.mjs +41 -6
- package/local-scope-import-expressions.mjs +80 -0
- package/local-scope-repository.mjs +35 -24
- package/local-typescript-function-reference.mjs +98 -0
- package/manifest.json +1 -1
- package/package.json +4 -2
- package/release-state.json +12 -10
- package/releases.json +12 -0
- package/reuse-discipline-release.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.52 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
- 修复 Vue/TypeScript 的 Function 类型和真实 Vue 回调 prop 被误判为动态依赖;解析不可变 const 的有限语言模块导入,记录实际依赖与哈希。补齐 Vue 候选依赖检查;未知运行时导入报告文件、行列和原因。测试采用隔离用户目录,越界、动态执行、未知删除与原任务写入校验继续保留。
|
|
6
|
+
- Fixed Vue/TypeScript Function types and genuine Vue callback props being misclassified as dynamic dependencies. Resolve finite immutable const-based language imports with actual dependency hashes and inspect Vue candidate dependencies. Unbounded runtime imports report file, line, column and reason. Tests use isolated user directories while path, dynamic-execution, unknown-deletion and original-task mutation checks remain enforced.
|
|
7
|
+
|
|
8
|
+
## 0.1.51 — 2026-10-16
|
|
9
|
+
|
|
10
|
+
- 修复入口阶段将自身生成的带引号 capabilities/unlock 命令误判为原生 Bash;正常 TypeScript 候选探测遇到普通文件祖先时继续回退,真实访问仍严格校验。路径拒绝会报告请求路径、阻断祖先、解析地址和原因。
|
|
11
|
+
- Fixed entry guards misclassifying their own quoted capabilities/unlock commands as native shell. Read-only TypeScript probes skip nonexistent children beneath ordinary files and continue valid fallbacks; actual access remains strict. Rejections identify the requested path, blocking ancestor, resolved target and reason.
|
|
12
|
+
|
|
3
13
|
## 0.1.50 — 2026-10-16
|
|
4
14
|
|
|
5
15
|
- 修复 tsconfig 的 npm 配置继承,支持包与子路径、exports/typesVersions 及数组继承;按真实 pnpm 来源解析并记录配置哈希。依赖保持只读,越界及无法确定规范来源的引用报告具体路径和原因,保留原任务重试。
|
package/IDE-EXECUTION.md
CHANGED
|
@@ -4,6 +4,8 @@ Register or sign in by email at https://entrorove.com. Enter the account key onc
|
|
|
4
4
|
|
|
5
5
|
If the IDE guard itself is the blocked step, run `entrorove unlock` directly from the affected IDE terminal. The command is offline and records a 15-minute lease bound to that IDE session; it does not call a hook, read a credential, accept the agreement, inspect the protocol or authorize project writes. The host session ID must be present in the environment or supplied as `ENTROROVE_SESSION`. Under the lease, `flow-cancel` and `flow-abandon` recover the original flow; successful `flow-abandon` releases its task binding in both the flow state and the IDE session record, even without a hook callback. Queued requests remain, and other IDE sessions stay guarded.
|
|
6
6
|
|
|
7
|
+
Capability discovery and offline unlock remain administrative calls during `entry`, before a flow exists. The guard recognizes its own rendered commands with quoted identity assignments, their order changed, or a literal `env` prefix containing only supported host/session identities; it rewrites approved calls to the installed runtime and current session. This does not authorize native scripts or unbound project writes.
|
|
8
|
+
|
|
7
9
|
To stop using Entrorove on this computer, run `entrorove uninstall` directly. It works offline, removes only the recorded Entrorove host hooks and pristine global skill, and leaves other host settings, credentials and task records in place. A hand-edited file is reported as preserved rather than deleted. This command does not inspect or change another project.
|
|
8
10
|
|
|
9
11
|
Every invocation rereads that same credential file, including clients already running. After an authenticated account page supplies the current key and `configure` synchronizes it once, all tasks under the same OS user automatically use the new value. A revoked or expired key cannot obtain new permissions for itself: return to the authenticated account page and synchronize once through JSON stdin. Status recovery only queries the original request; it never rotates credentials or repeats the operation.
|
|
@@ -64,7 +66,7 @@ The v2 progress sequence is durable and ordered: the first runtime step reports
|
|
|
64
66
|
|
|
65
67
|
When intake returns `workflow-gate`, connect the current repository before invoking that gate: run `npx @entrorove/skill scope-connect <absolute repositoryRoot>` with JSON `{"flowId":"<original flowId>","paths":["<existing source file>"]}` on stdin. Use the concrete existing source files in the task's scope; new files belong in the later plan's declared artifacts. The adapter enrolls an OS-private Ed25519 scope signer for this accepted account and task, answers a server challenge, copies and verifies real source files, and submits the signed facts. It sends hashes and scope metadata rather than source contents or the private key. Enrollment grants scope inspection only; execution evidence and write passes retain their separate authority checks. An operator-configured signer cannot be replaced. After a successful connection, use the returned `nextInput` for `continue`, or explicitly send `payload: {"targetFiles":["<same existing source file>"]}` to the original `workflow-gate`. The connector uses `paths`; the gate uses `targetFiles`. Refresh an expired snapshot by reconnecting the same flow and path set. Show an explicit blocked result; query the original request ID after an unknown transport outcome before considering another attempt.
|
|
66
68
|
|
|
67
|
-
Scope inspection resolves aliases from actual `tsconfig.json` or `jsconfig.json`, including relative and npm `extends` and ordered inheritance arrays. Npm configuration lookup uses the pinned TypeScript JSON-config resolver over controlled metadata reads, including scoped packages, subpaths, package `tsconfig`, `exports` and `typesVersions`. Configurations and package metadata enter the signed hashes; dependencies are read only, never executed. Repository-local pnpm links use their actual configuration location. Read-only external dependency packages support parents and mappings inside their reference boundary; references requiring an unrepresentable external canonical ancestor fail explicitly rather than selecting a different same-name repository package. For plugin source compiled by a host project whose config is outside the source's ancestors, include `importContexts: [{"sourcePrefix":"<plugin source directory>","configPath":"<actual host tsconfig.json>"}]` in `scope-connect` input. Inspect the real build configuration first; never invent an alias or mark an unresolved import as external. These context paths remain inside the repository, their hashes enter the signed facts, and the local task binding freezes them.
|
|
69
|
+
Scope inspection resolves aliases from actual `tsconfig.json` or `jsconfig.json`, including relative and npm `extends` and ordered inheritance arrays. Npm configuration lookup uses the pinned TypeScript JSON-config resolver over controlled metadata reads, including scoped packages, subpaths, package `tsconfig`, `exports` and `typesVersions`. Read-only candidate probes treat a child beneath an ordinary file as absent so normal TypeScript fallbacks continue; actual sources, control files and mutation targets retain strict validation. Rejected paths report the requested path, blocking ancestor, resolved target and reason, including broken or cyclic dependency links. Configurations and package metadata enter the signed hashes; dependencies are read only, never executed. Repository-local pnpm links use their actual configuration location. Read-only external dependency packages support parents and mappings inside their reference boundary; references requiring an unrepresentable external canonical ancestor fail explicitly rather than selecting a different same-name repository package. For plugin source compiled by a host project whose config is outside the source's ancestors, include `importContexts: [{"sourcePrefix":"<plugin source directory>","configPath":"<actual host tsconfig.json>"}]` in `scope-connect` input. Inspect the real build configuration first; never invent an alias or mark an unresolved import as external. These context paths remain inside the repository, their hashes enter the signed facts, and the local task binding freezes them.
|
|
68
70
|
|
|
69
71
|
Keep snapshot before-images distinct from a frozen execution plan's expected final file hashes. Controlled writes consume the original approved authority; the runner then verifies the actual planned final bytes. Never edit a signed plan or silently substitute a newer snapshot after an unknown or failed gate. Existing changes outside that authority require explicit revalidation of the same original flow.
|
|
70
72
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Execute an engineering task through Entrorove, preserving its origi
|
|
|
5
5
|
|
|
6
6
|
# entrorove · 熵巡
|
|
7
7
|
|
|
8
|
-
Version: 0.1.
|
|
8
|
+
Version: 0.1.52.
|
|
9
9
|
|
|
10
10
|
Entrorove applies to the whole task once selected. Reading this document or calling an entry once does not execute the task. On every later turn, including after compaction, continue the original task and its required next operation before dependent work. Keep the original goal, scope, account, flow, cloud task and failure records. Never describe planning, a reported step or model output as verified delivery.
|
|
11
11
|
|
package/ide-command.mjs
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
+
function literalWords(header) {
|
|
4
|
+
const words = [];
|
|
5
|
+
let word = '', quote = null, started = false;
|
|
6
|
+
for (let index = 0; index < header.length; index++) {
|
|
7
|
+
const character = header[index];
|
|
8
|
+
if (quote) {
|
|
9
|
+
if (character === quote) quote = null;
|
|
10
|
+
else if (quote === '"' && character === '\\') {
|
|
11
|
+
if (!['"', '\\'].includes(header[index + 1])) return null;
|
|
12
|
+
word += header[++index];
|
|
13
|
+
} else word += character;
|
|
14
|
+
} else if (character === "'" || character === '"') {
|
|
15
|
+
quote = character; started = true;
|
|
16
|
+
} else if (character === '\\') {
|
|
17
|
+
if (!["'", '"', '\\', ' ', '\t'].includes(header[index + 1])) return null;
|
|
18
|
+
word += header[++index]; started = true;
|
|
19
|
+
} else if (/\s/u.test(character)) {
|
|
20
|
+
if (started) { words.push(word); word = ''; started = false; }
|
|
21
|
+
} else { word += character; started = true; }
|
|
22
|
+
}
|
|
23
|
+
if (quote) return null;
|
|
24
|
+
if (started) words.push(word);
|
|
25
|
+
return words.length ? words : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
3
28
|
// Deliberately accepts a small literal shell grammar. Unknown commands are not
|
|
4
29
|
// classified as reads: the host must reject them or use the approved runner.
|
|
5
30
|
export function literalCommand(command) {
|
|
@@ -14,30 +39,37 @@ export function literalCommand(command) {
|
|
|
14
39
|
header = here[1];
|
|
15
40
|
}
|
|
16
41
|
if (/[\n\r\0`$<>;&|]/u.test(header)) return null;
|
|
17
|
-
const words =
|
|
18
|
-
|
|
19
|
-
while ((match = pattern.exec(header))) {
|
|
20
|
-
if (match.index !== end) return null;
|
|
21
|
-
words.push(match[1] ?? match[2] ?? match[3]); end = pattern.lastIndex;
|
|
22
|
-
}
|
|
23
|
-
if (end !== header.length || !words.length) return null;
|
|
42
|
+
const words = literalWords(header);
|
|
43
|
+
if (!words) return null;
|
|
24
44
|
return { words, ...(input ? { input } : {}) };
|
|
25
45
|
}
|
|
26
46
|
|
|
47
|
+
function sdkWords(parsed) {
|
|
48
|
+
const words = [...parsed.words], seen = new Set();
|
|
49
|
+
const env = ['env', '/usr/bin/env', '/bin/env'].includes(words[0]);
|
|
50
|
+
if (env) words.shift();
|
|
51
|
+
while (words[0]?.includes('=')) {
|
|
52
|
+
const assignment = words.shift(), separator = assignment.indexOf('=');
|
|
53
|
+
const key = assignment.slice(0, separator), value = assignment.slice(separator + 1);
|
|
54
|
+
if (seen.has(key)) return null;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
if (key === 'ENTROROVE_IDE') {
|
|
57
|
+
if (!/^[\p{L}\p{N} .+_-]{2,48}$/u.test(value)) return null;
|
|
58
|
+
} else if (!['ENTROROVE_SESSION', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID'].includes(key)
|
|
59
|
+
|| !/^[A-Za-z0-9_-]{1,200}$/u.test(value)) return null;
|
|
60
|
+
}
|
|
61
|
+
return words.length && (!env || seen.size) ? words : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
27
64
|
export function sdkCommand(parsed, runtime) {
|
|
28
65
|
if (!parsed) return null;
|
|
29
|
-
const words =
|
|
30
|
-
if (words
|
|
31
|
-
if (!/^ENTROROVE_IDE=[\p{L}\p{N} .+_-]{2,48}$/u.test(words.shift())) return null;
|
|
32
|
-
while (['ENTROROVE_SESSION=', 'CODEX_THREAD_ID='].some(prefix => words[0]?.startsWith(prefix))) {
|
|
33
|
-
if (!/^[A-Z_]+=[A-Za-z0-9_-]{1,200}$/u.test(words.shift())) return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
66
|
+
const words = sdkWords(parsed);
|
|
67
|
+
if (!words) return null;
|
|
36
68
|
let args;
|
|
37
69
|
if ([runtime.node, 'node'].includes(words[0]) && words[1] === runtime.cli) args = words.slice(2);
|
|
38
70
|
else if (['npx', 'npx.cmd'].includes(path.basename(words[0]))) {
|
|
39
71
|
words.shift();
|
|
40
|
-
while (['--yes', '--prefer-online'].includes(words[0])) words.shift();
|
|
72
|
+
while (['--yes', '--prefer-online', '--registry=https://registry.npmjs.org'].includes(words[0])) words.shift();
|
|
41
73
|
if (!['@entrorove/skill', '@entrorove/skill@latest', `@entrorove/skill@${runtime.version}`].includes(words.shift())) return null;
|
|
42
74
|
args = words;
|
|
43
75
|
} else if (path.basename(words[0]) === 'entrorove') {
|
|
@@ -83,6 +115,6 @@ export function readCommand(parsed) {
|
|
|
83
115
|
|
|
84
116
|
export function renderSdkCommand(call, runtime, sessionId, host = process.env.ENTROROVE_IDE ?? 'Codex') {
|
|
85
117
|
const quote = value => `'${String(value).replace(/'/gu, "'\\''")}'`;
|
|
86
|
-
const header = `ENTROROVE_IDE=${host} ENTROROVE_SESSION=${quote(sessionId)} CODEX_THREAD_ID=${quote(sessionId)} ${[runtime.node, runtime.cli, ...call.args].map(quote).join(' ')}`;
|
|
118
|
+
const header = `ENTROROVE_IDE=${quote(host)} ENTROROVE_SESSION=${quote(sessionId)} CODEX_THREAD_ID=${quote(sessionId)} ${[runtime.node, runtime.cli, ...call.args].map(quote).join(' ')}`;
|
|
87
119
|
return call.input ? `${header} <<'ENTROROVE_INPUT'\n${JSON.stringify(call.input)}\nENTROROVE_INPUT` : header;
|
|
88
120
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { archguardScriptSource } from './local-archguard-ast.mjs';
|
|
3
3
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { isTypeOnlyReference, isDynamicFunctionReference } from './local-typescript-function-reference.mjs';
|
|
5
|
+
import { createScopeImportResolver } from './local-scope-import-expressions.mjs';
|
|
4
6
|
|
|
5
7
|
export const disciplineAnalysisLimits = Object.freeze({ fileBytes: 1_048_576, sourceBytes: 8_388_608, files: 512, nodes: 200_000 });
|
|
6
8
|
function analysisLimit(file, reason, limit, required) {
|
|
@@ -77,12 +79,15 @@ function definition(source, node) {
|
|
|
77
79
|
return { ...location(source, node), symbol, name, exported: Boolean(exported), contract: Boolean(contract),
|
|
78
80
|
signatureDigest: contract ? sha256(signature) : null };
|
|
79
81
|
}
|
|
80
|
-
function unresolvedNode(node) {
|
|
81
|
-
if (
|
|
82
|
-
if (ts.
|
|
82
|
+
function unresolvedNode(node, resolve) {
|
|
83
|
+
if (isTypeOnlyReference(node)) return false;
|
|
84
|
+
if (ts.isElementAccessExpression(node) && (nameOf(node.argumentExpression) === null
|
|
85
|
+
|| ['eval', 'Function', 'createRequire', 'require'].includes(nameOf(node.argumentExpression)))) return true;
|
|
86
|
+
if (ts.isIdentifier(node) && (['eval', 'createRequire'].includes(node.text) || isDynamicFunctionReference(node))) return true;
|
|
83
87
|
if (ts.isCallExpression(node)) {
|
|
84
88
|
if (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
85
|
-
return node.arguments.length
|
|
89
|
+
return !node.arguments.length || node.arguments.length > (node.expression.kind === ts.SyntaxKind.ImportKeyword ? 2 : 1)
|
|
90
|
+
|| resolve(node.arguments[0]) === null;
|
|
86
91
|
}
|
|
87
92
|
if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression)
|
|
88
93
|
&& ['Reflect', 'Object'].includes(node.expression.expression.text) && ['get', 'set', 'defineProperty'].includes(node.expression.name.text)) return true;
|
|
@@ -148,8 +153,9 @@ export function analyzeDisciplineSources(files, { edges = [], unresolved = [], r
|
|
|
148
153
|
const program = programFor(sources, edges), checker = program.getTypeChecker(), definitions = [], mappings = [], references = new Map();
|
|
149
154
|
const unknown = [...unresolved, ...unsupported];
|
|
150
155
|
for (const source of sources.values()) {
|
|
156
|
+
const resolve = createScopeImportResolver(source, checker);
|
|
151
157
|
const visit = node => {
|
|
152
|
-
if (unresolvedNode(node)) unknown.push({ ...location(source, node), reason: 'dynamic-reference' });
|
|
158
|
+
if (unresolvedNode(node, resolve)) unknown.push({ ...location(source, node), reason: 'dynamic-reference' });
|
|
153
159
|
if (symbolNode(node)) {
|
|
154
160
|
const entry = definition(source, node);
|
|
155
161
|
if (entry) { const symbol = checker.getSymbolAtLocation(node.name); definitions.push({ entry, symbol, nameNode: node.name }); }
|
|
@@ -90,25 +90,28 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
|
|
|
90
90
|
if (!analyze) return { comparison };
|
|
91
91
|
// The existing resolver supplies actual aliases, package exports and import edges.
|
|
92
92
|
// Unsupported/dynamic references stay unknown; they are never converted to unused.
|
|
93
|
-
const roots = sources.filter(file => paths.includes(file.path) && /\.[cm]?[jt]sx
|
|
93
|
+
const roots = sources.filter(file => paths.includes(file.path) && /\.(?:[cm]?[jt]sx?|vue)$/i.test(file.path)).map(file => ({ path: file.path, exists: true }));
|
|
94
94
|
let graph;
|
|
95
95
|
try { graph = roots.length ? await importGraph(root, roots, input, disciplineAnalysisLimits) : { edges: [], dynamicSources: [], complete: true }; }
|
|
96
96
|
catch (error) {
|
|
97
97
|
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
98
98
|
graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ reason: error.code, message: error.message });
|
|
99
99
|
}
|
|
100
|
-
for (const [relative, content] of overrides) if (semanticSource(relative) && /\.[cm]?[jt]sx
|
|
100
|
+
for (const [relative, content] of overrides) if (semanticSource(relative) && /\.(?:[cm]?[jt]sx?|vue)$/i.test(relative)) {
|
|
101
101
|
try {
|
|
102
102
|
const candidate = await scopeCandidateImports(root, { path: relative, content }, input);
|
|
103
103
|
graph.edges = [...graph.edges.filter(edge => edge.from !== relative), ...candidate.edges];
|
|
104
|
-
if (candidate.dynamic) unresolved.push(
|
|
104
|
+
if (candidate.dynamic) unresolved.push(...(candidate.unresolvedImports?.length ? candidate.unresolvedImports
|
|
105
|
+
: [{ path: relative, reason: 'candidate-dynamic-import' }]));
|
|
105
106
|
} catch (error) {
|
|
106
107
|
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
107
108
|
unresolved.push({ path: relative, reason: error.code });
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
const analysis = analyzeDisciplineSources(sources, { edges: graph.edges, requiredPaths: [...required],
|
|
111
|
-
unresolved: [...unresolved, ...graph.
|
|
112
|
+
unresolved: [...unresolved, ...(graph.unresolvedImports ?? []),
|
|
113
|
+
...graph.dynamicSources.filter(item => !graph.unresolvedImports?.some(site => site.path === item.path))
|
|
114
|
+
.map(item => ({ path: item.path, reason: 'dynamic-import' }))] });
|
|
112
115
|
analysis.definitions.push(...await nativeDisciplineDefinitions(sources));
|
|
113
116
|
return { comparison, analysis, scanComplete: graph.complete && analysis.unresolved.length === 0 };
|
|
114
117
|
}
|
package/local-scope-files.mjs
CHANGED
|
@@ -36,6 +36,26 @@ async function assertDependencyReference(root, target, relative) {
|
|
|
36
36
|
}
|
|
37
37
|
return resolved;
|
|
38
38
|
}
|
|
39
|
+
async function invalidScopeTarget(root, relative, current, reason, cause) {
|
|
40
|
+
let resolved = current, resolutionError;
|
|
41
|
+
try { resolved = await fs.realpath(current); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
resolutionError = error.code;
|
|
44
|
+
try { resolved = path.resolve(path.dirname(current), await fs.readlink(current)); }
|
|
45
|
+
catch { /* A non-directory ancestor is already its own blocking target. */ }
|
|
46
|
+
}
|
|
47
|
+
const blockingPath = path.relative(root, current).split(path.sep).join('/');
|
|
48
|
+
const explanation = {
|
|
49
|
+
'non-directory-ancestor': 'the ancestor is not a directory',
|
|
50
|
+
'symlink-not-allowed': 'strict scope access does not allow symlinks',
|
|
51
|
+
'unresolvable-symlink': 'the symlink cannot be resolved to an actual dependency target',
|
|
52
|
+
'not-regular-file': 'the source must be a regular file',
|
|
53
|
+
'unsupported-target-type': 'the target must be a regular file or directory',
|
|
54
|
+
}[reason];
|
|
55
|
+
const unavailable = cause || resolutionError;
|
|
56
|
+
throw Object.assign(new Error(`Scope path ${JSON.stringify(relative)} is blocked at ${JSON.stringify(blockingPath)} (${JSON.stringify(current)}), resolved to ${JSON.stringify(resolved)} because ${explanation}${unavailable ? ` (${unavailable})` : ''}`),
|
|
57
|
+
{ code: 'ENTROROVE_FILE_INVALID', reason, path: relative, blockingPath, resolvedPath: resolved });
|
|
58
|
+
}
|
|
39
59
|
export async function scopeTarget(root, relative, allowMissing = false, allowDependency = false) {
|
|
40
60
|
let current = root;
|
|
41
61
|
const parts = scopePath(relative).split('/');
|
|
@@ -48,17 +68,32 @@ export async function scopeTarget(root, relative, allowMissing = false, allowDep
|
|
|
48
68
|
throw error;
|
|
49
69
|
}
|
|
50
70
|
if (stat.isSymbolicLink()) {
|
|
51
|
-
if (!followDependency)
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
if (!followDependency) await invalidScopeTarget(root, relative, current, 'symlink-not-allowed');
|
|
72
|
+
try {
|
|
73
|
+
await assertDependencyReference(root, current, relative);
|
|
74
|
+
stat = await fs.stat(current);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) {
|
|
77
|
+
await invalidScopeTarget(root, relative, current, 'unresolvable-symlink', error.code);
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
54
81
|
}
|
|
55
|
-
if (index < parts.length - 1 && !stat.isDirectory())
|
|
82
|
+
if (index < parts.length - 1 && !stat.isDirectory()) await invalidScopeTarget(root, relative, current, 'non-directory-ancestor');
|
|
56
83
|
if (index === parts.length - 1) return { target: current, exists: true, stat, dependency: followDependency };
|
|
57
84
|
}
|
|
58
85
|
}
|
|
86
|
+
// Only speculative, read-only resolver candidates may treat a non-directory ancestor as absent.
|
|
87
|
+
export async function scopeProbeTarget(root, relative, allowDependency = false) {
|
|
88
|
+
try { return await scopeTarget(root, relative, true, allowDependency); }
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== 'ENTROROVE_FILE_INVALID' || error.reason !== 'non-directory-ancestor') throw error;
|
|
91
|
+
return { target: path.join(root, relative), exists: false, stat: null };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
59
94
|
async function safeScopeRead(root, relative, input, allowDependency = true) {
|
|
60
95
|
const dependency = allowDependency && dependencyReference(relative), target = await scopeTarget(root, relative, false, dependency);
|
|
61
|
-
if (!target.stat.isFile())
|
|
96
|
+
if (!target.stat.isFile()) await invalidScopeTarget(root, relative, target.target, 'not-regular-file');
|
|
62
97
|
if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
|
|
63
98
|
const handle = await fs.open(target.target, constants.O_RDONLY | (dependency ? 0 : (constants.O_NOFOLLOW ?? 0)));
|
|
64
99
|
try {
|
|
@@ -87,7 +122,7 @@ export async function scopeFiles(root, requested, knownFiles = []) {
|
|
|
87
122
|
const item = await scopeTarget(root, relative, known.has(relative));
|
|
88
123
|
if (!item.exists) { found.add(relative); return; }
|
|
89
124
|
if (item.stat.isFile()) { found.add(relative); return; }
|
|
90
|
-
if (!item.stat.isDirectory())
|
|
125
|
+
if (!item.stat.isDirectory()) await invalidScopeTarget(root, relative, item.target, 'unsupported-target-type');
|
|
91
126
|
for (const name of (await fs.readdir(item.target)).sort()) if (!excluded.has(name)) await visit(`${relative}/${name}`);
|
|
92
127
|
}
|
|
93
128
|
for (const relative of known) if (requested.some(target => target === '.' || target === relative || relative.startsWith(`${target}/`))) found.add(relative);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
|
|
4
|
+
const limits = Object.freeze({ alternatives: 64, nodes: 512, depth: 32 });
|
|
5
|
+
function unwrap(node) {
|
|
6
|
+
while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
|
|
7
|
+
|| ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
|
|
8
|
+
return node;
|
|
9
|
+
}
|
|
10
|
+
function sourceChecker(source) {
|
|
11
|
+
const name = path.posix.resolve('/', source.fileName.replaceAll('\\', '/'));
|
|
12
|
+
const matches = value => path.posix.resolve('/', value.replaceAll('\\', '/')) === name;
|
|
13
|
+
const host = { getSourceFile: filename => matches(filename) ? source : undefined,
|
|
14
|
+
getDefaultLibFileName: () => '/entrorove-no-library.d.ts', writeFile: () => {},
|
|
15
|
+
getCurrentDirectory: () => '/', getDirectories: () => [], fileExists: matches,
|
|
16
|
+
readFile: filename => matches(filename) ? source.text : undefined,
|
|
17
|
+
getCanonicalFileName: filename => filename, useCaseSensitiveFileNames: () => true, getNewLine: () => '\n' };
|
|
18
|
+
return ts.createProgram([name], { noLib: true, noResolve: true, allowJs: true }, host).getTypeChecker();
|
|
19
|
+
}
|
|
20
|
+
function product(left, right) {
|
|
21
|
+
if (!left || !right || left.length * right.length > limits.alternatives) return null;
|
|
22
|
+
return [...new Set(left.flatMap(prefix => right.map(suffix => prefix + suffix)))];
|
|
23
|
+
}
|
|
24
|
+
/** Resolve literal alternatives without executing source, trusting type assertions, or reading other files. */
|
|
25
|
+
export function createScopeImportResolver(source, suppliedChecker) {
|
|
26
|
+
let checker = suppliedChecker;
|
|
27
|
+
const resolve = expression => {
|
|
28
|
+
let nodes = 0;
|
|
29
|
+
const values = (raw, seen = new Set(), depth = 0) => {
|
|
30
|
+
const node = unwrap(raw);
|
|
31
|
+
if (!node || depth > limits.depth || ++nodes > limits.nodes) return null;
|
|
32
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
|
|
33
|
+
if (ts.isConditionalExpression(node)) {
|
|
34
|
+
const left = values(node.whenTrue, seen, depth + 1), right = values(node.whenFalse, seen, depth + 1);
|
|
35
|
+
return left && right && left.length + right.length <= limits.alternatives ? [...new Set([...left, ...right])] : null;
|
|
36
|
+
}
|
|
37
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
38
|
+
return product(values(node.left, seen, depth + 1), values(node.right, seen, depth + 1));
|
|
39
|
+
}
|
|
40
|
+
if (ts.isTemplateExpression(node)) {
|
|
41
|
+
let result = [node.head.text];
|
|
42
|
+
for (const span of node.templateSpans) {
|
|
43
|
+
result = product(result, values(span.expression, seen, depth + 1));
|
|
44
|
+
if (!result) return null;
|
|
45
|
+
result = result.map(value => value + span.literal.text);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
if (!ts.isIdentifier(node)) return null;
|
|
50
|
+
checker ??= sourceChecker(source);
|
|
51
|
+
const declarations = checker.getSymbolAtLocation(node)?.declarations;
|
|
52
|
+
if (declarations?.length !== 1) return null;
|
|
53
|
+
const declaration = declarations[0];
|
|
54
|
+
if (seen.has(declaration) || declaration.getSourceFile() !== source) return null;
|
|
55
|
+
const next = new Set([...seen, declaration]);
|
|
56
|
+
if (ts.isVariableDeclaration(declaration) && ts.isVariableDeclarationList(declaration.parent)
|
|
57
|
+
&& declaration.parent.flags & ts.NodeFlags.Const && declaration.initializer
|
|
58
|
+
&& !(ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Ambient)
|
|
59
|
+
&& !source.isDeclarationFile) return values(declaration.initializer, next, depth + 1);
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
const alternatives = values(expression);
|
|
63
|
+
if (alternatives?.some(value => !value || /[\u0000-\u001f\u007f]/.test(value))) {
|
|
64
|
+
throw Object.assign(new Error('module literal is empty or contains control characters'), { code: 'ENTROROVE_IMPORT_INVALID' });
|
|
65
|
+
}
|
|
66
|
+
return alternatives?.sort() ?? null;
|
|
67
|
+
};
|
|
68
|
+
return resolve;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function unresolvedImportSite(source, expression) {
|
|
72
|
+
const node = unwrap(expression), position = source.getLineAndCharacterOfPosition((node ?? source).getStart(source));
|
|
73
|
+
const kind = node ? ts.SyntaxKind[node.kind] : 'MissingArgument';
|
|
74
|
+
const reason = node && ts.isIdentifier(node) ? 'import-binding-not-finite-constant'
|
|
75
|
+
: node && ts.isTemplateExpression(node) ? 'import-template-has-unbounded-substitution'
|
|
76
|
+
: node && ts.isBinaryExpression(node) ? 'import-concatenation-has-unbounded-operand'
|
|
77
|
+
: 'import-expression-not-statically-finite';
|
|
78
|
+
return { line: position.line + 1, column: position.character + 1, kind, reason,
|
|
79
|
+
message: `Cannot verify ${kind} import: only immutable literal alternatives (at most ${limits.alternatives}) are statically bounded; runtime values and mutable bindings remain unresolved` };
|
|
80
|
+
}
|
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { builtinModules } from 'node:module';
|
|
6
6
|
import ts from 'typescript';
|
|
7
7
|
import { parse as parseVue, parseCache as vueParseCache } from '@vue/compiler-sfc';
|
|
8
|
-
import { lineCount, observedScopeRisk, packageBoundary, scopeFail, scopeFiles, scopeRead, scopeTarget, stableScope } from './local-scope-files.mjs';
|
|
8
|
+
import { lineCount, observedScopeRisk, packageBoundary, scopeFail, scopeFiles, scopeProbeTarget, scopeRead, scopeTarget, stableScope } from './local-scope-files.mjs';
|
|
9
9
|
import { sha256 } from './local-integrity.mjs';
|
|
10
10
|
import { workspaceImport, workspaceIndex } from './local-scope-workspaces.mjs';
|
|
11
11
|
import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
|
|
@@ -13,6 +13,8 @@ import { observeBudgetScopeGit, scopeGitBudgetEnforced } from './local-scope-git
|
|
|
13
13
|
import { scopeGitMetadata } from './local-scope-git-reads.mjs';
|
|
14
14
|
import { scopeTsconfigImports } from './local-scope-config-imports.mjs';
|
|
15
15
|
import { scopeGitExecutable } from './local-git-executable.mjs';
|
|
16
|
+
import { createScopeImportResolver, unresolvedImportSite } from './local-scope-import-expressions.mjs';
|
|
17
|
+
import { isTypeOnlyReference, isDynamicFunctionReference } from './local-typescript-function-reference.mjs';
|
|
16
18
|
|
|
17
19
|
const execute = promisify(execFile);
|
|
18
20
|
async function git(root, args, allowed = [], input = { repositoryRoot: root }) {
|
|
@@ -94,11 +96,6 @@ function staticString(expression) {
|
|
|
94
96
|
if (!expression || (!ts.isStringLiteral(expression) && !ts.isNoSubstitutionTemplateLiteral(expression))) return null;
|
|
95
97
|
return expression.text;
|
|
96
98
|
}
|
|
97
|
-
function moduleLiteral(expression) {
|
|
98
|
-
const value = staticString(expression);
|
|
99
|
-
if (value !== null && (!value || /[\u0000-\u001f\u007f]/.test(value))) scopeFail('ENTROROVE_IMPORT_INVALID', 'module literal is empty or contains control characters');
|
|
100
|
-
return value;
|
|
101
|
-
}
|
|
102
99
|
function globalObject(expression) {
|
|
103
100
|
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
104
101
|
|| ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression) || ts.isSatisfiesExpression(expression))) expression = expression.expression;
|
|
@@ -106,12 +103,16 @@ function globalObject(expression) {
|
|
|
106
103
|
}
|
|
107
104
|
// The same pinned TypeScript parser as the cloud scope analyzer visits actual
|
|
108
105
|
// expressions inside templates and JSX; literal bodies never become code.
|
|
109
|
-
function scriptImports(source, filename) {
|
|
106
|
+
function scriptImports(source, filename, context = {}) {
|
|
110
107
|
const parsed = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true,
|
|
111
108
|
scriptKinds.get(path.extname(filename).toLowerCase()) ?? ts.ScriptKind.TS);
|
|
112
109
|
if (parsed.parseDiagnostics.length) scopeFail('ENTROROVE_IMPORT_INVALID', 'source has invalid or unterminated syntax');
|
|
113
|
-
const found = new Set(); let dynamic = false;
|
|
114
|
-
const add = expression => {
|
|
110
|
+
const found = new Set(), unresolvedImports = [], resolve = createScopeImportResolver(parsed); let dynamic = false;
|
|
111
|
+
const add = expression => {
|
|
112
|
+
const values = resolve(expression);
|
|
113
|
+
if (values === null) { dynamic = true; unresolvedImports.push(unresolvedImportSite(parsed, expression)); }
|
|
114
|
+
else for (const value of values) found.add(value);
|
|
115
|
+
};
|
|
115
116
|
const visit = node => {
|
|
116
117
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { if (node.moduleSpecifier) add(node.moduleSpecifier); }
|
|
117
118
|
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(node.moduleReference.expression);
|
|
@@ -120,24 +121,29 @@ function scriptImports(source, filename) {
|
|
|
120
121
|
const importer = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
121
122
|
const requireCall = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
122
123
|
if (importer || requireCall) {
|
|
123
|
-
if (!node.arguments.length || node.arguments.length > (importer ? 2 : 1))
|
|
124
|
+
if (!node.arguments.length || node.arguments.length > (importer ? 2 : 1)) {
|
|
125
|
+
dynamic = true;
|
|
126
|
+
unresolvedImports.push({ ...unresolvedImportSite(parsed, node), reason: 'import-argument-list-not-supported',
|
|
127
|
+
message: 'Import inspection requires one module argument and at most one import-attributes argument' });
|
|
128
|
+
}
|
|
124
129
|
else add(node.arguments[0]);
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
const runtimeReference = !isTypeOnlyReference(node);
|
|
133
|
+
if (runtimeReference && ts.isIdentifier(node)) {
|
|
134
|
+
if (dynamicNames.has(node.text) && (node.text !== 'Function' || isDynamicFunctionReference(node, context))) dynamic = true;
|
|
129
135
|
if (node.text === 'require' && !(ts.isCallExpression(node.parent) && node.parent.expression === node)
|
|
130
136
|
&& !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)
|
|
131
137
|
&& !(ts.isMethodDeclaration(node.parent) && node.parent.name === node)) dynamic = true;
|
|
132
138
|
}
|
|
133
|
-
if (ts.isElementAccessExpression(node)) {
|
|
139
|
+
if (runtimeReference && ts.isElementAccessExpression(node)) {
|
|
134
140
|
const member = staticString(node.argumentExpression);
|
|
135
141
|
if (dynamicNames.has(member) || member === 'require' || member === null && globalObject(node.expression)) dynamic = true;
|
|
136
142
|
}
|
|
137
|
-
if (ts.isPropertyAccessExpression(node) && node.name.text === 'require' && globalObject(node.expression)) dynamic = true;
|
|
143
|
+
if (runtimeReference && ts.isPropertyAccessExpression(node) && node.name.text === 'require' && globalObject(node.expression)) dynamic = true;
|
|
138
144
|
ts.forEachChild(node, visit);
|
|
139
145
|
};
|
|
140
|
-
visit(parsed); return { specifiers: [...found].sort(), dynamic };
|
|
146
|
+
visit(parsed); return { specifiers: [...found].sort(), dynamic, unresolvedImports };
|
|
141
147
|
}
|
|
142
148
|
function vueScripts(source, filename) {
|
|
143
149
|
const { descriptor, errors } = parseVue(source, { filename, sourceMap: false,
|
|
@@ -152,7 +158,8 @@ function vueScripts(source, filename) {
|
|
|
152
158
|
const language = block.lang ?? 'js';
|
|
153
159
|
if (!['js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx'].includes(language)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'component script language is unsupported');
|
|
154
160
|
const extension = { javascript: 'js', typescript: 'ts' }[language] ?? language;
|
|
155
|
-
scripts.push({ source: block.src ? `import ${JSON.stringify(block.src)};` : block.content, filename: `${filename}.${extension}
|
|
161
|
+
scripts.push({ source: block.src ? `import ${JSON.stringify(block.src)};` : block.content, filename: `${filename}.${extension}`,
|
|
162
|
+
lineOffset: block.loc.start.line - 1, vueComponent: true, vueSetup: block === descriptor.scriptSetup });
|
|
156
163
|
}
|
|
157
164
|
const seen = new WeakSet();
|
|
158
165
|
const walk = node => {
|
|
@@ -172,10 +179,11 @@ function vueScripts(source, filename) {
|
|
|
172
179
|
function importSpecifiers(content, filename) {
|
|
173
180
|
try {
|
|
174
181
|
const source = content.toString('utf8'), parts = path.extname(filename).toLowerCase() === '.vue' ? vueScripts(source, filename) : [{ source, filename }];
|
|
175
|
-
const specifiers = new Set(); let dynamic = false;
|
|
176
|
-
for (const part of parts) { const parsed = scriptImports(part.source, part.filename); dynamic ||= parsed.dynamic;
|
|
182
|
+
const specifiers = new Set(), unresolvedImports = []; let dynamic = false;
|
|
183
|
+
for (const part of parts) { const parsed = scriptImports(part.source, part.filename, part); dynamic ||= parsed.dynamic;
|
|
184
|
+
unresolvedImports.push(...parsed.unresolvedImports.map(site => ({ ...site, line: site.line + (part.lineOffset ?? 0) })));
|
|
177
185
|
for (const specifier of parsed.specifiers) specifiers.add(specifier); }
|
|
178
|
-
return { specifiers: [...specifiers].sort(), dynamic };
|
|
186
|
+
return { specifiers: [...specifiers].sort(), dynamic, unresolvedImports };
|
|
179
187
|
} catch (error) {
|
|
180
188
|
if (typeof error?.code === 'string' && error.code.startsWith('ENTROROVE_')) throw error;
|
|
181
189
|
if (error instanceof RangeError) scopeFail('ENTROROVE_SCOPE_LIMIT', 'dependency syntax exceeds the supported parser or traversal bound');
|
|
@@ -228,7 +236,7 @@ async function externalConfigReference(root, relative, resolved) {
|
|
|
228
236
|
return boundary;
|
|
229
237
|
}
|
|
230
238
|
async function configMetadata(root, relative, input) {
|
|
231
|
-
const target = await
|
|
239
|
+
const target = await scopeProbeTarget(root, relative, dependencyReference(relative));
|
|
232
240
|
if (!target.exists || !target.stat.isFile()) throw Object.assign(new Error(`Missing configuration file: ${relative}`), { code: 'ENOENT' });
|
|
233
241
|
const read = await scopeRead(root, relative, input), resolved = await fs.realpath(target.target);
|
|
234
242
|
const physical = path.relative(root, resolved).split(path.sep).join('/');
|
|
@@ -241,7 +249,7 @@ async function resolveLocalImport(root, relative, required = true) {
|
|
|
241
249
|
if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
|
|
242
250
|
const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
|
|
243
251
|
for (const candidate of [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)]) {
|
|
244
|
-
const item = await
|
|
252
|
+
const item = await scopeProbeTarget(root, candidate, dependencyReference(candidate));
|
|
245
253
|
if (item.exists && item.stat.isFile()) return candidate;
|
|
246
254
|
}
|
|
247
255
|
if (required) scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
|
|
@@ -250,7 +258,7 @@ async function resolveLocalImport(root, relative, required = true) {
|
|
|
250
258
|
export async function importGraph(root, originals, input = { repositoryRoot: root }, limits = {}) {
|
|
251
259
|
const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
|
|
252
260
|
let sourceBytes = 0;
|
|
253
|
-
const dynamicSources = [];
|
|
261
|
+
const dynamicSources = [], unresolvedImports = [];
|
|
254
262
|
const workspace = await workspaceIndex(root, input);
|
|
255
263
|
const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
|
|
256
264
|
while (pending.length) {
|
|
@@ -273,6 +281,7 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
273
281
|
for (const observed of aliases.observations) metadata.set(observed.path, observed);
|
|
274
282
|
const declarations = declarationModules(read.content, relative);
|
|
275
283
|
const imports = importSpecifiers(read.content, relative); dynamic ||= imports.dynamic;
|
|
284
|
+
unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
|
|
276
285
|
if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
277
286
|
for (const specifier of imports.specifiers.sort()) {
|
|
278
287
|
const target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations);
|
|
@@ -286,7 +295,8 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
286
295
|
const workspaceMetadata = [...metadata.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
287
296
|
await stableScope(root, workspaceMetadata, input);
|
|
288
297
|
return { nodes, edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)),
|
|
289
|
-
dynamic, complete: !dynamic, dynamicSources: dynamicSources.sort((a, b) => a.path.localeCompare(b.path)), workspaceMetadata
|
|
298
|
+
dynamic, complete: !dynamic, dynamicSources: dynamicSources.sort((a, b) => a.path.localeCompare(b.path)), workspaceMetadata,
|
|
299
|
+
...(unresolvedImports.length ? { unresolvedImports } : {}) };
|
|
290
300
|
}
|
|
291
301
|
export async function scopeCandidateImports(root, file, input = { repositoryRoot: root }) {
|
|
292
302
|
const parsed = importSpecifiers(Buffer.from(file.content), file.path);
|
|
@@ -299,7 +309,8 @@ export async function scopeCandidateImports(root, file, input = { repositoryRoot
|
|
|
299
309
|
const target = await resolveImport(root, file.path, specifier, workspace, input, aliases, context?.configPath, declarations);
|
|
300
310
|
edges.push({ from: file.path, to: target, external: target === null, specifier });
|
|
301
311
|
}
|
|
302
|
-
return { edges, dynamic: parsed.dynamic
|
|
312
|
+
return { edges, dynamic: parsed.dynamic,
|
|
313
|
+
...(parsed.unresolvedImports.length ? { unresolvedImports: parsed.unresolvedImports.map(site => ({ path: file.path, ...site })) } : {}) };
|
|
303
314
|
}
|
|
304
315
|
export async function collectScopeFacts(root, requested, baselineRef, knownFiles = [], input = { repositoryRoot: root }) {
|
|
305
316
|
const selected = await scopeFiles(root, requested, knownFiles); const files = [];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
const bindings = new WeakMap();
|
|
4
|
+
const propertyName = node => node && (ts.isIdentifier(node) || ts.isStringLiteral(node)) ? node.text : null;
|
|
5
|
+
const wrapper = node => ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)
|
|
6
|
+
|| ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node);
|
|
7
|
+
const unwrap = node => {
|
|
8
|
+
while (node && wrapper(node)) node = node.expression;
|
|
9
|
+
return node;
|
|
10
|
+
};
|
|
11
|
+
export function isTypeOnlyReference(node) {
|
|
12
|
+
for (let current = node; current?.parent; current = current.parent) {
|
|
13
|
+
const parent = current.parent;
|
|
14
|
+
if (ts.isExpressionWithTypeArguments(parent) && ts.isHeritageClause(parent.parent)) {
|
|
15
|
+
return parent.parent.token !== ts.SyntaxKind.ExtendsKeyword
|
|
16
|
+
|| !(ts.isClassDeclaration(parent.parent.parent) || ts.isClassExpression(parent.parent.parent));
|
|
17
|
+
}
|
|
18
|
+
if (ts.isTypeNode(parent) || ts.isInterfaceDeclaration(parent) || ts.isTypeAliasDeclaration(parent)
|
|
19
|
+
|| ts.isTypeParameterDeclaration(parent)) return true;
|
|
20
|
+
if ((ts.isImportClause(parent) || ts.isImportSpecifier(parent)) && parent.isTypeOnly) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function bindingNames(node) {
|
|
25
|
+
if (ts.isIdentifier(node)) return [node.text];
|
|
26
|
+
if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node)) {
|
|
27
|
+
return node.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(element.name) : []);
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
function sourceBindings(source) {
|
|
32
|
+
if (bindings.has(source)) return bindings.get(source);
|
|
33
|
+
const found = new Map();
|
|
34
|
+
const add = (name, api = null) => found.set(name, [...found.get(name) ?? [], api]);
|
|
35
|
+
const visit = node => {
|
|
36
|
+
if (ts.isImportSpecifier(node) || ts.isNamespaceImport(node)) {
|
|
37
|
+
let declaration = node.parent;
|
|
38
|
+
while (declaration && !ts.isImportDeclaration(declaration)) declaration = declaration.parent;
|
|
39
|
+
const vue = declaration && ts.isStringLiteral(declaration.moduleSpecifier) && declaration.moduleSpecifier.text === 'vue'
|
|
40
|
+
&& !declaration.importClause?.isTypeOnly && !node.isTypeOnly;
|
|
41
|
+
add(node.name.text, vue ? ts.isNamespaceImport(node) ? '*' : (node.propertyName ?? node.name).text : null);
|
|
42
|
+
} else if (ts.isImportClause(node) && node.name) add(node.name.text);
|
|
43
|
+
else if (ts.isVariableDeclaration(node) || ts.isParameter(node)) bindingNames(node.name).forEach(name => add(name));
|
|
44
|
+
else if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isClassDeclaration(node)
|
|
45
|
+
|| ts.isClassExpression(node) || ts.isImportEqualsDeclaration(node) || ts.isEnumDeclaration(node)) && node.name) add(node.name.text);
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
};
|
|
48
|
+
visit(source); bindings.set(source, found); return found;
|
|
49
|
+
}
|
|
50
|
+
function vueCall(node, api, context) {
|
|
51
|
+
if (!ts.isCallExpression(node) || node.arguments.length !== 1) return false;
|
|
52
|
+
const expression = unwrap(node.expression), found = sourceBindings(node.getSourceFile());
|
|
53
|
+
if (ts.isIdentifier(expression)) {
|
|
54
|
+
const declarations = found.get(expression.text) ?? [];
|
|
55
|
+
return declarations.length === 1 && declarations[0] === api
|
|
56
|
+
|| api === 'defineProps' && context.vueSetup === true && expression.text === api && !declarations.length;
|
|
57
|
+
}
|
|
58
|
+
return ts.isPropertyAccessExpression(expression) && expression.name.text === api && ts.isIdentifier(expression.expression)
|
|
59
|
+
&& found.get(expression.expression.text)?.length === 1 && found.get(expression.expression.text)[0] === '*';
|
|
60
|
+
}
|
|
61
|
+
function transparentParent(node) {
|
|
62
|
+
while (node.parent && wrapper(node.parent) && node.parent.expression === node) node = node.parent;
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
function vuePropsContainer(node, context) {
|
|
66
|
+
const value = transparentParent(node), parent = value.parent;
|
|
67
|
+
if (parent && vueCall(parent, 'defineProps', context) && parent.arguments[0] === value) return true;
|
|
68
|
+
if (!ts.isPropertyAssignment(parent) || propertyName(parent.name) !== 'props' || parent.initializer !== value) return false;
|
|
69
|
+
const component = transparentParent(parent.parent), owner = component.parent;
|
|
70
|
+
return context.vueComponent === true && ts.isExportAssignment(owner) && owner.expression === component
|
|
71
|
+
|| owner && vueCall(owner, 'defineComponent', context) && owner.arguments[0] === component;
|
|
72
|
+
}
|
|
73
|
+
function vueFunctionMetadata(node, context) {
|
|
74
|
+
let value = transparentParent(node);
|
|
75
|
+
if (ts.isArrayLiteralExpression(value.parent)) value = transparentParent(value.parent);
|
|
76
|
+
const field = value.parent;
|
|
77
|
+
if (!ts.isPropertyAssignment(field) || field.initializer !== value) return false;
|
|
78
|
+
let props = field.parent;
|
|
79
|
+
if (ts.isObjectLiteralExpression(props) && vuePropsContainer(props, context)) return true;
|
|
80
|
+
if (propertyName(field.name) === 'type') {
|
|
81
|
+
const descriptor = transparentParent(props), property = descriptor.parent;
|
|
82
|
+
if (!ts.isPropertyAssignment(property) || property.initializer !== descriptor) return false;
|
|
83
|
+
props = property.parent;
|
|
84
|
+
}
|
|
85
|
+
return ts.isObjectLiteralExpression(props) && vuePropsContainer(props, context);
|
|
86
|
+
}
|
|
87
|
+
function declarationName(node) {
|
|
88
|
+
const parent = node.parent;
|
|
89
|
+
return parent?.name === node && (ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent)
|
|
90
|
+
|| ts.isMethodDeclaration(parent) || ts.isMethodSignature(parent) || ts.isPropertyDeclaration(parent)
|
|
91
|
+
|| ts.isFunctionDeclaration(parent) || ts.isClassDeclaration(parent) || ts.isVariableDeclaration(parent)
|
|
92
|
+
|| ts.isParameter(parent) || ts.isEnumDeclaration(parent) || ts.isEnumMember(parent)
|
|
93
|
+
|| ts.isImportSpecifier(parent) || ts.isImportClause(parent));
|
|
94
|
+
}
|
|
95
|
+
export function isDynamicFunctionReference(node, context = {}) {
|
|
96
|
+
if (!ts.isIdentifier(node) || node.text !== 'Function') return false;
|
|
97
|
+
return !isTypeOnlyReference(node) && !declarationName(node) && !vueFunctionMetadata(node, context);
|
|
98
|
+
}
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrorove/skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.52",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"entrorove": "cli.mjs"
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"local-scope-candidate.mjs",
|
|
133
133
|
"local-scope-context.mjs",
|
|
134
134
|
"local-scope-files.mjs",
|
|
135
|
+
"local-scope-import-expressions.mjs",
|
|
135
136
|
"local-scope-repository.mjs",
|
|
136
137
|
"local-scope-signature.mjs",
|
|
137
138
|
"local-scope-config-imports.mjs",
|
|
@@ -212,6 +213,7 @@
|
|
|
212
213
|
"ide-queue.mjs",
|
|
213
214
|
"local-discipline-actions.mjs",
|
|
214
215
|
"local-discipline-analysis.mjs",
|
|
216
|
+
"local-typescript-function-reference.mjs",
|
|
215
217
|
"local-discipline-batch.mjs",
|
|
216
218
|
"local-discipline-contracts.mjs",
|
|
217
219
|
"local-discipline-gate.mjs",
|
|
@@ -233,7 +235,7 @@
|
|
|
233
235
|
"reuse-discipline-release.mjs"
|
|
234
236
|
],
|
|
235
237
|
"scripts": {
|
|
236
|
-
"test": "node
|
|
238
|
+
"test": "node test/run.mjs",
|
|
237
239
|
"prepublishOnly": "node release.mjs",
|
|
238
240
|
"prepare:archguard": "node archguard-runtime-build.mjs",
|
|
239
241
|
"check:archguard": "node archguard-runtime-build.mjs --check",
|
package/release-state.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "entrorove.release-state/1.0",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"digest": "
|
|
3
|
+
"version": "0.1.52",
|
|
4
|
+
"digest": "81fb53f113036a9ffdaa64ac99f3f9c0cc4180c623997ee009d4bc63e58a9ab8",
|
|
5
5
|
"files": {
|
|
6
6
|
"ide-code-mode.mjs": "f3f53357ba09727b3b597851976a24de93287c0faca5052448277d0904bb61f7",
|
|
7
|
-
"ide-command.mjs": "
|
|
7
|
+
"ide-command.mjs": "86472c2f89c17e13a6fb0e41794add983400aac3e626891dbb3812e34320e229",
|
|
8
8
|
"ide-hook.mjs": "2f00e56de1e1149f117714d5dd334196f1a3f206c3bfab541393bc0f625e0108",
|
|
9
9
|
"ide-install.mjs": "10247efadcfeef5da001022d12080bd0f789e67fdf7862bcc65db73dda3bbe57",
|
|
10
10
|
"ide-session.mjs": "dc256e77b03303932d354768c0e79384340a3137e7421b4ae9c869f7e58e1a3a",
|
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
"ide-results.mjs": "dfe0d3f773c65a9fc21056538d7005012c982ed4509bc0410d7f62813d0863fd",
|
|
13
13
|
"ide-queue.mjs": "806e716c775fd0079417027b135977c8516f47dddb0bc5ca19e32a355dab0e9e",
|
|
14
14
|
"local-discipline-actions.mjs": "2379c08d0f49c44068f8239db7e514d75885f4606b1b88a7a36e4ff19167caca",
|
|
15
|
-
"local-discipline-analysis.mjs": "
|
|
15
|
+
"local-discipline-analysis.mjs": "e887abdd1c6d6ba59ef7323e9035528f0491e5458ca8159985ec26e1ca67a4af",
|
|
16
16
|
"local-discipline-batch.mjs": "2cc90dbae55efdee0c445da399c0a9acafa92e915344c25208ebca554fc4c9e9",
|
|
17
|
+
"local-typescript-function-reference.mjs": "af682b479fe0d69a5ecd62232406bb8078408ec8710733cfed9ae6b059762ba5",
|
|
18
|
+
"local-scope-import-expressions.mjs": "6623dea5501c4dc24e12c99e0776d65650bbe752c85c9509d022ba1f61bd5111",
|
|
17
19
|
"local-discipline-contracts.mjs": "6cf8a3d132fdb9074eb1441600afe7aa199df33e88a71a660d9db534c14f19e4",
|
|
18
20
|
"local-discipline-contracts.d.mts": "defc684c78dc88afa1c54340ab4a01ae36db7aa11257cbe1f5cd323f608c78c4",
|
|
19
21
|
"local-discipline-gate.mjs": "04b1ba092d7f4342a0e77cfbeb44d9634dbce9fec0c79e6a1e4b11a662d904ac",
|
|
20
22
|
"local-discipline-native.mjs": "8b99b5ce951cab3c1426428ad807650c61c0ffb2c346d747b73b99057b382049",
|
|
21
23
|
"local-discipline-operations.mjs": "9fcb7b8889b7718d42e1d37936485d8198d5017d405e0cdbbabdbbd12fcc9d48",
|
|
22
|
-
"local-discipline-repository.mjs": "
|
|
24
|
+
"local-discipline-repository.mjs": "b6392e02ecae333dfdc8df41f203fd69bed035d88b15b16d112ab1897b3c297c",
|
|
23
25
|
"local-discipline-snapshot.mjs": "8fd35b2cfbece8f64579f144f589a66dec9b6022d50fe931c4c1f886cb83134d",
|
|
24
26
|
"local-runner-discipline.mjs": "86f451de2f1e12a24f5596a29828891c1eba13cd4714ed0c18a77d66cecef934",
|
|
25
27
|
"local-integrity.d.mts": "21357f306234bd841c0d3d90bf2d09f7663f63746f897b9f38190428ae8198b3",
|
|
@@ -29,9 +31,9 @@
|
|
|
29
31
|
"runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
|
|
30
32
|
"snapshot-retention.mjs": "4f8919fe8ec49fb4c23e080a8ad2fa3a9282bca3fffe7cf55c64e89ef316697e",
|
|
31
33
|
"snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
|
|
32
|
-
"IDE-EXECUTION.md": "
|
|
34
|
+
"IDE-EXECUTION.md": "2cb952bb919261e9e420c9d83659eba013e5b1b175d80da03b50b660edfe4f31",
|
|
33
35
|
"REUSE-DISCIPLINE.md": "5b214cf313e7f715f52d54e28093a2d038f0f196ca64b15d1c49587b9d8eecf8",
|
|
34
|
-
"reuse-discipline-release.mjs": "
|
|
36
|
+
"reuse-discipline-release.mjs": "2935898d1aa807fb982a78d7ab5cb1abe05e3c8fc945dc0909da8098872aa1ac",
|
|
35
37
|
"local-final-gate16.mjs": "083aec470670e6cdb7ad2ab27b7a51b53a9ec3a8b4121cb9cbfa17d9940892c2",
|
|
36
38
|
"local-final-gate16-checks.mjs": "1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a",
|
|
37
39
|
"local-final-gate16-worker.mjs": "344d64b6c1c5552b96960e0d8514a876a3dbfa9762ab9eb749038e8351727121",
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
"CAPABILITY-MAP.md": "a0f0468a098725ced52a96dd8e22bfc7d1ce7681a8446ebf7dae3696c76c3385",
|
|
82
84
|
"AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
|
|
83
85
|
"DELIVERY-BOUNDARIES.md": "d1bb2895e23fc14bf1c76707bd1f6f03b39695501a78a0b7289dc37b099889dc",
|
|
84
|
-
"package.json": "
|
|
86
|
+
"package.json": "d385aa543bff1f373a1fb8464c712c4ea04e1b2938a8603ddecca903161cbac3",
|
|
85
87
|
"manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
|
|
86
88
|
"release.mjs": "e206cb6cabbb02542317bce283f7ba674621e67765a3eacef5741f2d6b1c58b2",
|
|
87
89
|
"local-cli.mjs": "11461e4cf4f6a8612ff275bf8296ec57ff22281156cb2c72834d3c764ba422a1",
|
|
@@ -121,8 +123,8 @@
|
|
|
121
123
|
"local-stage-boundary.mjs": "84c3bb5beab29306274c5ca8a78f19a83ed664ae1b93a8372bb493158b304fe3",
|
|
122
124
|
"local-scope-candidate.mjs": "8f0bd9606e70d4a0ebacdd3cd600ec3afed85e2bb3e1760aa1144cf55d4e028a",
|
|
123
125
|
"local-scope-context.mjs": "79753abfc6c76089d17f003a1be537dfaea10d6731c542f7fc53f2a9996ed164",
|
|
124
|
-
"local-scope-files.mjs": "
|
|
125
|
-
"local-scope-repository.mjs": "
|
|
126
|
+
"local-scope-files.mjs": "4c14a5e226dd5518aeb37f199b1aa896943a66d41e5a1757a3b3fd8a79446d76",
|
|
127
|
+
"local-scope-repository.mjs": "d9d4c81c3f401c38c33b8dd7bdf8ebaf4658d1afd1cce408a7acd37f8836a6df",
|
|
126
128
|
"local-scope-git.mjs": "53bbbb6c4795db711b5bc164591a6ad79a561427da046d6bbe997dae6eb82829",
|
|
127
129
|
"local-scope-git-reads.mjs": "5d8a2636bab955cb325cb693db7423e69512355887ae5b5af3b8cb615f5ddad0",
|
|
128
130
|
"local-git-executable.mjs": "9391d716529025b1de3ee006df658a9d765b6ef29ad24ef9b7436829318e651e",
|
package/releases.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.52",
|
|
4
|
+
"date": "2026-10-17",
|
|
5
|
+
"zh": "修复 Vue/TypeScript 的 Function 类型和真实 Vue 回调 prop 被误判为动态依赖;解析不可变 const 的有限语言模块导入,记录实际依赖与哈希。补齐 Vue 候选依赖检查;未知运行时导入报告文件、行列和原因。测试采用隔离用户目录,越界、动态执行、未知删除与原任务写入校验继续保留。",
|
|
6
|
+
"en": "Fixed Vue/TypeScript Function types and genuine Vue callback props being misclassified as dynamic dependencies. Resolve finite immutable const-based language imports with actual dependency hashes and inspect Vue candidate dependencies. Unbounded runtime imports report file, line, column and reason. Tests use isolated user directories while path, dynamic-execution, unknown-deletion and original-task mutation checks remain enforced."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.51",
|
|
10
|
+
"date": "2026-10-16",
|
|
11
|
+
"zh": "修复入口阶段将自身生成的带引号 capabilities/unlock 命令误判为原生 Bash;正常 TypeScript 候选探测遇到普通文件祖先时继续回退,真实访问仍严格校验。路径拒绝会报告请求路径、阻断祖先、解析地址和原因。",
|
|
12
|
+
"en": "Fixed entry guards misclassifying their own quoted capabilities/unlock commands as native shell. Read-only TypeScript probes skip nonexistent children beneath ordinary files and continue valid fallbacks; actual access remains strict. Rejections identify the requested path, blocking ancestor, resolved target and reason."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.1.50",
|
|
4
16
|
"date": "2026-10-16",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const ide = ['ide-code-mode.mjs', 'ide-command.mjs', 'ide-hook.mjs', 'ide-install.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs'];
|
|
2
2
|
const discipline = ['local-discipline-actions.mjs', 'local-discipline-analysis.mjs', 'local-discipline-batch.mjs',
|
|
3
|
+
'local-typescript-function-reference.mjs', 'local-scope-import-expressions.mjs',
|
|
3
4
|
'local-discipline-contracts.mjs', 'local-discipline-contracts.d.mts', 'local-discipline-gate.mjs',
|
|
4
5
|
'local-discipline-native.mjs', 'local-discipline-operations.mjs', 'local-discipline-repository.mjs',
|
|
5
6
|
'local-discipline-snapshot.mjs', 'local-runner-discipline.mjs', 'local-integrity.d.mts'];
|
|
@@ -15,4 +16,6 @@ export const reuseDisciplineReleaseLabels = Object.fromEntries([
|
|
|
15
16
|
...cleanup.map(file => [file, { zh: '验收后快照自动清理', en: 'verified snapshot cleanup' }]),
|
|
16
17
|
...documentation.map(file => [file, { zh: 'IDE 执行与复用纪律文档', en: 'IDE execution and reuse discipline documentation' }]),
|
|
17
18
|
['reuse-discipline-release.mjs', { zh: '复用纪律版本追踪', en: 'reuse discipline release tracking' }],
|
|
19
|
+
['local-typescript-function-reference.mjs', { zh: 'TypeScript 类型与 Vue 属性构造器引用分析', en: 'TypeScript type and Vue prop constructor references' }],
|
|
20
|
+
['local-scope-import-expressions.mjs', { zh: '不可变有限动态导入与逐站点未核实诊断', en: 'immutable finite imports and precise unresolved import diagnostics' }],
|
|
18
21
|
]);
|