sift-light 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ByteRange, SourceReference } from "./source-document.js";
|
|
2
|
+
import type { SiftLightInput } from "./service.js";
|
|
3
|
+
import type { SearchScopeDetails, ResultStatistics } from "./types.js";
|
|
4
|
+
import type { ValidationDetails } from "./validation-types.js";
|
|
5
|
+
import type { ConceptSourceSummary } from "./concept-source-generation.js";
|
|
6
|
+
|
|
7
|
+
export type CoverageStatus = "complete" | "partial" | "skipped" | "not-applicable";
|
|
8
|
+
|
|
9
|
+
export const SEMANTIC_JUDGE_CLASSIFICATIONS = [
|
|
10
|
+
"implementation-candidate",
|
|
11
|
+
"caller-candidate",
|
|
12
|
+
"mention-only",
|
|
13
|
+
"documentation",
|
|
14
|
+
"test-only",
|
|
15
|
+
"irrelevant",
|
|
16
|
+
"uncertain",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type SemanticJudgeClassification = (typeof SEMANTIC_JUDGE_CLASSIFICATIONS)[number];
|
|
20
|
+
|
|
21
|
+
export const SEMANTIC_JUDGE_NON_PROOF_CLAIM =
|
|
22
|
+
"semantic classification only; local static and runtime verification is not asserted";
|
|
23
|
+
|
|
24
|
+
export type SemanticJudgeStatus = "disabled" | "complete" | "partial" | "failed";
|
|
25
|
+
|
|
26
|
+
export interface SemanticJudgeJudgment {
|
|
27
|
+
candidateIndex: number;
|
|
28
|
+
classification: SemanticJudgeClassification;
|
|
29
|
+
probability: number;
|
|
30
|
+
confidence?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SemanticJudgeDetails {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
provider: string;
|
|
36
|
+
status: SemanticJudgeStatus;
|
|
37
|
+
model?: string;
|
|
38
|
+
maxCandidates: number;
|
|
39
|
+
candidatesConsidered: number;
|
|
40
|
+
judgedCandidates: number;
|
|
41
|
+
candidatesUnjudged: number;
|
|
42
|
+
batchesAttempted: number;
|
|
43
|
+
batchesCompleted: number;
|
|
44
|
+
batchesFailed: number;
|
|
45
|
+
batchesSplit: number;
|
|
46
|
+
classificationCounts: Record<string, number>;
|
|
47
|
+
inputTokens?: number;
|
|
48
|
+
outputTokens?: number;
|
|
49
|
+
elapsedMs?: number;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConceptScoreProfile {
|
|
54
|
+
count: number;
|
|
55
|
+
top: number;
|
|
56
|
+
second?: number;
|
|
57
|
+
median: number;
|
|
58
|
+
min: number;
|
|
59
|
+
spread: number;
|
|
60
|
+
topMargin?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AnalysisItem {
|
|
64
|
+
path: string;
|
|
65
|
+
line: number;
|
|
66
|
+
label: string;
|
|
67
|
+
excerpt?: string;
|
|
68
|
+
source?: SourceReference;
|
|
69
|
+
range?: ByteRange;
|
|
70
|
+
details?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AnalysisDetails {
|
|
74
|
+
kind:
|
|
75
|
+
| "concept"
|
|
76
|
+
| "hybrid"
|
|
77
|
+
| "structure"
|
|
78
|
+
| "files"
|
|
79
|
+
| "roles"
|
|
80
|
+
| "file-and"
|
|
81
|
+
| "function-and"
|
|
82
|
+
| "changes"
|
|
83
|
+
| "outline"
|
|
84
|
+
| "imports"
|
|
85
|
+
| "tests"
|
|
86
|
+
| "any-of"
|
|
87
|
+
| "validate";
|
|
88
|
+
unit:
|
|
89
|
+
| "occurrences"
|
|
90
|
+
| "files"
|
|
91
|
+
| "functions"
|
|
92
|
+
| "symbols"
|
|
93
|
+
| "relationships"
|
|
94
|
+
| "test-candidates"
|
|
95
|
+
| "evidence-items";
|
|
96
|
+
totalItems: number;
|
|
97
|
+
returnedItems: number;
|
|
98
|
+
modelOutput?: boolean;
|
|
99
|
+
semanticJudge?: SemanticJudgeDetails;
|
|
100
|
+
statistics?: ResultStatistics;
|
|
101
|
+
items: (AnalysisItem & { index: number; sourceId?: number; inspect?: SiftLightInput })[];
|
|
102
|
+
sources?: SourceReference[];
|
|
103
|
+
inspectCursor?: string;
|
|
104
|
+
reasons: string[];
|
|
105
|
+
filesRead?: number;
|
|
106
|
+
bytesRead?: number;
|
|
107
|
+
counts?: Record<string, number>;
|
|
108
|
+
termCounts?: { term: string; retainedOccurrences: number }[];
|
|
109
|
+
termCountsOffset?: number;
|
|
110
|
+
totalTerms?: number;
|
|
111
|
+
termCountsNextRequest?: SiftLightInput;
|
|
112
|
+
matchesRequest?: SiftLightInput;
|
|
113
|
+
changes?: { base: string; target: string; scope: string; side: string };
|
|
114
|
+
scope?: SearchScopeDetails;
|
|
115
|
+
chunks?: {
|
|
116
|
+
chunked: boolean;
|
|
117
|
+
count: number;
|
|
118
|
+
maxTermsPerChunk: number;
|
|
119
|
+
execution: "single" | "bounded-parallel";
|
|
120
|
+
};
|
|
121
|
+
coverage?: Record<string, CoverageStatus>;
|
|
122
|
+
validation?: ValidationDetails;
|
|
123
|
+
stats?: {
|
|
124
|
+
inferencePeakRssBytes?: number;
|
|
125
|
+
passagesRanked?: number;
|
|
126
|
+
elapsedMs?: number;
|
|
127
|
+
filesEnumerated?: number;
|
|
128
|
+
filesAdmitted?: number;
|
|
129
|
+
filesParsed?: number;
|
|
130
|
+
filesSkipped?: number;
|
|
131
|
+
cacheHits?: number;
|
|
132
|
+
conceptCacheHits?: number;
|
|
133
|
+
conceptCacheMisses?: number;
|
|
134
|
+
conceptCacheBytes?: number;
|
|
135
|
+
conceptCacheMaxBytes?: number;
|
|
136
|
+
conceptWindowsRanked?: number;
|
|
137
|
+
parseMs?: number;
|
|
138
|
+
budgetExhausted?: boolean;
|
|
139
|
+
scoreProfile?: ConceptScoreProfile;
|
|
140
|
+
};
|
|
141
|
+
sourceGeneration?: ConceptSourceSummary;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AnalysisResultSet {
|
|
145
|
+
kind: AnalysisDetails["kind"];
|
|
146
|
+
unit: AnalysisDetails["unit"];
|
|
147
|
+
items: AnalysisItem[];
|
|
148
|
+
partial: boolean;
|
|
149
|
+
reasons: string[];
|
|
150
|
+
semanticJudge?: SemanticJudgeDetails;
|
|
151
|
+
filesRead?: number;
|
|
152
|
+
bytesRead?: number;
|
|
153
|
+
counts?: Record<string, number>;
|
|
154
|
+
termCounts?: { term: string; retainedOccurrences: number }[];
|
|
155
|
+
changes?: AnalysisDetails["changes"];
|
|
156
|
+
scope?: SearchScopeDetails;
|
|
157
|
+
chunks?: AnalysisDetails["chunks"];
|
|
158
|
+
coverage?: Record<string, CoverageStatus>;
|
|
159
|
+
stats?: AnalysisDetails["stats"];
|
|
160
|
+
sourceGeneration?: ConceptSourceSummary;
|
|
161
|
+
redact?: boolean;
|
|
162
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { SiftLightError } from "./errors.js";
|
|
3
|
+
import { normalizeRequest } from "./request.js";
|
|
4
|
+
import type { RipgrepRunner } from "./rg.js";
|
|
5
|
+
import { getSourceRevision, sameSourceRevision } from "./source.js";
|
|
6
|
+
import {
|
|
7
|
+
MAX_SOURCE_REVISION_CONCURRENCY,
|
|
8
|
+
MAX_AUDIT_LITERAL_CHARACTERS,
|
|
9
|
+
MAX_RESULT_BYTES,
|
|
10
|
+
type AuditFindingDetails,
|
|
11
|
+
type AuditPatternInput,
|
|
12
|
+
type SearchScopeDetails,
|
|
13
|
+
type SearchScan,
|
|
14
|
+
type SourceRevision,
|
|
15
|
+
type SiftLightResult,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import type { SiftLightInput } from "./service.js";
|
|
18
|
+
import { listWorkspaceFiles } from "./workspace-files.js";
|
|
19
|
+
|
|
20
|
+
const MAX_AUDIT_PATTERNS = 32;
|
|
21
|
+
const MAX_AUDIT_CHANGED_FILES = 20;
|
|
22
|
+
const MAX_AUDIT_EVIDENCE = 3;
|
|
23
|
+
const MAX_AUDIT_IGNORED_FILES = 20;
|
|
24
|
+
const MAX_AUDIT_REASONS = 20;
|
|
25
|
+
const MAX_AUDIT_REASON_PATH_SAMPLES = 3;
|
|
26
|
+
const MAX_AUDIT_SAMPLE_BYTES = 2_048;
|
|
27
|
+
|
|
28
|
+
function validatePatterns(patterns: AuditPatternInput[] | undefined): AuditPatternInput[] {
|
|
29
|
+
if (!patterns?.length || patterns.length > MAX_AUDIT_PATTERNS)
|
|
30
|
+
throw new SiftLightError(
|
|
31
|
+
`mode=audit requires 1–${String(MAX_AUDIT_PATTERNS)} literal patterns`,
|
|
32
|
+
);
|
|
33
|
+
const ids = new Set<string>();
|
|
34
|
+
return patterns.map((pattern) => {
|
|
35
|
+
if (
|
|
36
|
+
!pattern ||
|
|
37
|
+
typeof pattern !== "object" ||
|
|
38
|
+
Array.isArray(pattern) ||
|
|
39
|
+
Object.keys(pattern).some((key) => key !== "id" && key !== "literal")
|
|
40
|
+
)
|
|
41
|
+
throw new SiftLightError("Each audit pattern accepts only id and literal fields");
|
|
42
|
+
if (
|
|
43
|
+
!pattern ||
|
|
44
|
+
typeof pattern.id !== "string" ||
|
|
45
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/u.test(pattern.id)
|
|
46
|
+
)
|
|
47
|
+
throw new SiftLightError(
|
|
48
|
+
"Each audit pattern id must use 1–64 letters, digits, underscores or hyphens",
|
|
49
|
+
);
|
|
50
|
+
if (ids.has(pattern.id)) throw new SiftLightError("Audit pattern ids must be unique");
|
|
51
|
+
ids.add(pattern.id);
|
|
52
|
+
if (
|
|
53
|
+
typeof pattern.literal !== "string" ||
|
|
54
|
+
!pattern.literal.length ||
|
|
55
|
+
pattern.literal.length > MAX_AUDIT_LITERAL_CHARACTERS ||
|
|
56
|
+
Buffer.byteLength(pattern.literal) > MAX_AUDIT_LITERAL_CHARACTERS ||
|
|
57
|
+
!pattern.literal.isWellFormed() ||
|
|
58
|
+
/[\r\n\0]/u.test(pattern.literal)
|
|
59
|
+
)
|
|
60
|
+
throw new SiftLightError(
|
|
61
|
+
`Each audit literal must be nonempty, single-line text of at most ${String(MAX_AUDIT_LITERAL_CHARACTERS)} characters and UTF-8 bytes`,
|
|
62
|
+
);
|
|
63
|
+
return { id: pattern.id, literal: pattern.literal };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function revisionsFor(
|
|
68
|
+
cwd: string,
|
|
69
|
+
paths: readonly string[],
|
|
70
|
+
signal?: AbortSignal,
|
|
71
|
+
): Promise<{ revisions: Map<string, SourceRevision>; unavailable: string[] }> {
|
|
72
|
+
const revisions = new Map<string, SourceRevision>();
|
|
73
|
+
const unavailable: string[] = [];
|
|
74
|
+
for (let offset = 0; offset < paths.length; offset += MAX_SOURCE_REVISION_CONCURRENCY) {
|
|
75
|
+
if (signal?.aborted) throw signal.reason;
|
|
76
|
+
const batch = paths.slice(offset, offset + MAX_SOURCE_REVISION_CONCURRENCY);
|
|
77
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded batches cap metadata concurrency.
|
|
78
|
+
await Promise.all(
|
|
79
|
+
batch.map(async (path) => {
|
|
80
|
+
const revision = await getSourceRevision(resolve(cwd, path), () => unavailable.push(path));
|
|
81
|
+
if (revision) revisions.set(path, revision);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { revisions, unavailable };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function changedFiles(
|
|
89
|
+
beforePaths: readonly string[],
|
|
90
|
+
afterPaths: readonly string[],
|
|
91
|
+
before: ReadonlyMap<string, SourceRevision>,
|
|
92
|
+
after: ReadonlyMap<string, SourceRevision>,
|
|
93
|
+
): string[] {
|
|
94
|
+
const all = new Set([...beforePaths, ...afterPaths]);
|
|
95
|
+
return [...all]
|
|
96
|
+
.filter((path) => {
|
|
97
|
+
const left = before.get(path);
|
|
98
|
+
const right = after.get(path);
|
|
99
|
+
return left === undefined || right === undefined || !sameSourceRevision(left, right);
|
|
100
|
+
})
|
|
101
|
+
.toSorted();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function auditScope(input: SiftLightInput): SearchScopeDetails {
|
|
105
|
+
const path = input.path?.replace(/^@/u, "") || ".";
|
|
106
|
+
return {
|
|
107
|
+
path,
|
|
108
|
+
requestedPath: path,
|
|
109
|
+
glob: input.glob === undefined ? [] : Array.isArray(input.glob) ? input.glob : [input.glob],
|
|
110
|
+
exclude:
|
|
111
|
+
input.exclude === undefined
|
|
112
|
+
? []
|
|
113
|
+
: Array.isArray(input.exclude)
|
|
114
|
+
? input.exclude
|
|
115
|
+
: [input.exclude],
|
|
116
|
+
hidden: input.hidden ?? true,
|
|
117
|
+
ignorePolicy: input.ignorePolicy ?? "respect",
|
|
118
|
+
expandedToProjectRoot: false,
|
|
119
|
+
assertion: path === "." ? "project-wide" : "requested-scope",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findingText(finding: AuditFindingDetails): string {
|
|
124
|
+
const firstEvidence = finding.evidence[0];
|
|
125
|
+
const evidence = firstEvidence
|
|
126
|
+
? `; evidence=${JSON.stringify(`${firstEvidence.path}:${String(firstEvidence.line)}`)}${finding.evidence.length > 1 ? `; ${String(finding.evidence.length - 1)} more evidence item(s) in structured receipt` : ""}`
|
|
127
|
+
: "";
|
|
128
|
+
return `- ${finding.id}: ${finding.status}; ${String(finding.matches)} match(es) across ${String(finding.files)} file(s)${evidence}; reproduce=${JSON.stringify(finding.reproduction)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function boundedValues(
|
|
132
|
+
values: readonly string[],
|
|
133
|
+
maxItems: number,
|
|
134
|
+
maxBytes: number,
|
|
135
|
+
): { values: string[]; omitted: number } {
|
|
136
|
+
const retained: string[] = [];
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
let omitted = 0;
|
|
139
|
+
for (const value of values) {
|
|
140
|
+
const size = Buffer.byteLength(JSON.stringify(value));
|
|
141
|
+
if (retained.length >= maxItems || bytes + size > maxBytes) {
|
|
142
|
+
omitted += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
retained.push(value);
|
|
146
|
+
bytes += size;
|
|
147
|
+
}
|
|
148
|
+
return { values: retained, omitted };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function boundedAuditText(required: readonly string[], optional: readonly string[]): string {
|
|
152
|
+
const requiredText = required.join("\n");
|
|
153
|
+
const omittedMarkerReserve = 128;
|
|
154
|
+
if (Buffer.byteLength(requiredText) > MAX_RESULT_BYTES - omittedMarkerReserve)
|
|
155
|
+
throw new SiftLightError(
|
|
156
|
+
`Audit reproduction evidence exceeds the ${String(MAX_RESULT_BYTES)} byte result limit; narrow the scope or use fewer patterns`,
|
|
157
|
+
);
|
|
158
|
+
const retained = [...required];
|
|
159
|
+
let bytes = Buffer.byteLength(requiredText);
|
|
160
|
+
let omitted = 0;
|
|
161
|
+
for (const line of optional) {
|
|
162
|
+
const size = Buffer.byteLength(line) + 1;
|
|
163
|
+
if (bytes + size > MAX_RESULT_BYTES - omittedMarkerReserve) {
|
|
164
|
+
omitted += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
retained.push(line);
|
|
168
|
+
bytes += size;
|
|
169
|
+
}
|
|
170
|
+
if (omitted > 0)
|
|
171
|
+
retained.push(`Optional receipt lines omitted by result budget: ${String(omitted)}.`);
|
|
172
|
+
const text = retained.join("\n");
|
|
173
|
+
if (Buffer.byteLength(text) > MAX_RESULT_BYTES)
|
|
174
|
+
throw new SiftLightError("Audit result exceeded its byte budget");
|
|
175
|
+
return text;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unavailableReason(stage: "Initial" | "Final", paths: readonly string[]): string[] {
|
|
179
|
+
if (paths.length === 0) return [];
|
|
180
|
+
const omitted = Math.max(0, paths.length - MAX_AUDIT_REASON_PATH_SAMPLES);
|
|
181
|
+
return [
|
|
182
|
+
`${stage} metadata unavailable for ${String(paths.length)} file(s)`,
|
|
183
|
+
...paths
|
|
184
|
+
.slice(0, MAX_AUDIT_REASON_PATH_SAMPLES)
|
|
185
|
+
.map((path) => `${stage} metadata-unavailable sample: ${JSON.stringify(path)}`),
|
|
186
|
+
...(omitted ? [`${stage} metadata-unavailable samples omitted: ${String(omitted)}`] : []),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function runAuditSearch(
|
|
191
|
+
input: SiftLightInput,
|
|
192
|
+
cwd: string,
|
|
193
|
+
runRipgrep: RipgrepRunner,
|
|
194
|
+
signal?: AbortSignal,
|
|
195
|
+
): Promise<SiftLightResult> {
|
|
196
|
+
const patterns = validatePatterns(input.patterns);
|
|
197
|
+
const scope = auditScope(input);
|
|
198
|
+
const ignorePolicy = scope.ignorePolicy ?? "respect";
|
|
199
|
+
const fileOptions = {
|
|
200
|
+
path: scope.path,
|
|
201
|
+
glob: scope.glob,
|
|
202
|
+
exclude: scope.exclude,
|
|
203
|
+
hidden: scope.hidden,
|
|
204
|
+
...(ignorePolicy === "include" ? { ignore: false, ignoreParents: false } : {}),
|
|
205
|
+
};
|
|
206
|
+
const [startFiles, startAllFiles] = await Promise.all([
|
|
207
|
+
listWorkspaceFiles(cwd, signal, fileOptions),
|
|
208
|
+
ignorePolicy === "respect"
|
|
209
|
+
? listWorkspaceFiles(cwd, signal, { ...fileOptions, ignore: false, ignoreParents: false })
|
|
210
|
+
: Promise.resolve(undefined),
|
|
211
|
+
]);
|
|
212
|
+
const before = await revisionsFor(cwd, (startAllFiles ?? startFiles).paths, signal);
|
|
213
|
+
const scans: SearchScan[] = [];
|
|
214
|
+
for (const pattern of patterns) {
|
|
215
|
+
const request = {
|
|
216
|
+
...normalizeRequest({
|
|
217
|
+
pattern: pattern.literal,
|
|
218
|
+
literal: true,
|
|
219
|
+
path: scope.path,
|
|
220
|
+
glob: scope.glob,
|
|
221
|
+
exclude: scope.exclude,
|
|
222
|
+
hidden: scope.hidden,
|
|
223
|
+
ignorePolicy,
|
|
224
|
+
scope: "strict",
|
|
225
|
+
}),
|
|
226
|
+
binaryAsText: true,
|
|
227
|
+
};
|
|
228
|
+
// oxlint-disable-next-line no-await-in-loop -- one declared pattern at a time keeps process use bounded.
|
|
229
|
+
scans.push(await runRipgrep(request, cwd, signal));
|
|
230
|
+
}
|
|
231
|
+
const [endFiles, endAllFiles] = await Promise.all([
|
|
232
|
+
listWorkspaceFiles(cwd, signal, fileOptions),
|
|
233
|
+
ignorePolicy === "respect"
|
|
234
|
+
? listWorkspaceFiles(cwd, signal, { ...fileOptions, ignore: false, ignoreParents: false })
|
|
235
|
+
: Promise.resolve(undefined),
|
|
236
|
+
]);
|
|
237
|
+
const after = await revisionsFor(cwd, (endAllFiles ?? endFiles).paths, signal);
|
|
238
|
+
const changed = changedFiles(
|
|
239
|
+
(startAllFiles ?? startFiles).paths,
|
|
240
|
+
(endAllFiles ?? endFiles).paths,
|
|
241
|
+
before.revisions,
|
|
242
|
+
after.revisions,
|
|
243
|
+
);
|
|
244
|
+
const startAdmitted = new Set(startFiles.paths);
|
|
245
|
+
const startIgnoredPaths = startAllFiles?.paths.filter((path) => !startAdmitted.has(path)) ?? [];
|
|
246
|
+
const filesDiscovered = (startAllFiles ?? startFiles).paths.length;
|
|
247
|
+
const filesAdmitted = startFiles.paths.length;
|
|
248
|
+
const searchedFiles = Math.min(...scans.map((scan) => scan.searchedFileCount ?? 0));
|
|
249
|
+
const filesSkippedOther = Math.max(0, filesAdmitted - searchedFiles);
|
|
250
|
+
const allReasons = new Set<string>([
|
|
251
|
+
...startFiles.reasons,
|
|
252
|
+
...endFiles.reasons,
|
|
253
|
+
...(startAllFiles?.reasons ?? []),
|
|
254
|
+
...(endAllFiles?.reasons ?? []),
|
|
255
|
+
...unavailableReason("Initial", before.unavailable),
|
|
256
|
+
...unavailableReason("Final", after.unavailable),
|
|
257
|
+
...scans.flatMap((scan) => scan.retention?.reasons ?? []),
|
|
258
|
+
]);
|
|
259
|
+
if (startIgnoredPaths.length > 0)
|
|
260
|
+
allReasons.add(`${String(startIgnoredPaths.length)} file(s) excluded by ignore rules`);
|
|
261
|
+
if (filesSkippedOther > 0)
|
|
262
|
+
allReasons.add(`${String(filesSkippedOther)} admitted file(s) were not searched for content`);
|
|
263
|
+
if (changed.length > 0) allReasons.add("The declared source set changed during the audit");
|
|
264
|
+
const reasonList = [...allReasons];
|
|
265
|
+
const boundedReasons = boundedValues(reasonList, MAX_AUDIT_REASONS, MAX_AUDIT_SAMPLE_BYTES);
|
|
266
|
+
const reasons = boundedReasons.values;
|
|
267
|
+
const reasonsOmitted = boundedReasons.omitted;
|
|
268
|
+
const filesystemComplete =
|
|
269
|
+
!startFiles.partial &&
|
|
270
|
+
!endFiles.partial &&
|
|
271
|
+
!(startAllFiles?.partial ?? false) &&
|
|
272
|
+
!(endAllFiles?.partial ?? false) &&
|
|
273
|
+
startIgnoredPaths.length === 0 &&
|
|
274
|
+
before.unavailable.length === 0 &&
|
|
275
|
+
after.unavailable.length === 0;
|
|
276
|
+
const searchComplete = scans.every(
|
|
277
|
+
(scan) => scan.snapshotComplete && (scan.filesystemCoverage ?? "complete") === "complete",
|
|
278
|
+
);
|
|
279
|
+
const stable = changed.length === 0;
|
|
280
|
+
const closureComplete = filesystemComplete && searchComplete && stable && filesSkippedOther === 0;
|
|
281
|
+
const findings = patterns.map<AuditFindingDetails>((pattern, index) => {
|
|
282
|
+
const scan = scans[index];
|
|
283
|
+
if (!scan) throw new Error("Audit scan result missing");
|
|
284
|
+
const status =
|
|
285
|
+
scan.totalMatches > 0
|
|
286
|
+
? "present"
|
|
287
|
+
: closureComplete
|
|
288
|
+
? "absent_with_complete_coverage"
|
|
289
|
+
: "unknown";
|
|
290
|
+
return {
|
|
291
|
+
id: pattern.id,
|
|
292
|
+
status,
|
|
293
|
+
matches: scan.totalMatches,
|
|
294
|
+
files: scan.fileCounts.size,
|
|
295
|
+
evidence: scan.matches.slice(0, MAX_AUDIT_EVIDENCE).map((match) => ({
|
|
296
|
+
path: match.displayPath,
|
|
297
|
+
line: match.lineNumber,
|
|
298
|
+
})),
|
|
299
|
+
reproduction: {
|
|
300
|
+
mode: "matches",
|
|
301
|
+
pattern: pattern.literal,
|
|
302
|
+
literal: true,
|
|
303
|
+
path: scope.path,
|
|
304
|
+
glob: [...scope.glob],
|
|
305
|
+
exclude: [...scope.exclude],
|
|
306
|
+
hidden: scope.hidden,
|
|
307
|
+
ignorePolicy,
|
|
308
|
+
scope: "strict",
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
const stabilityStatus: "stable" | "changed_during_search" | "unknown" =
|
|
313
|
+
before.unavailable.length || after.unavailable.length
|
|
314
|
+
? "unknown"
|
|
315
|
+
: changed.length
|
|
316
|
+
? "changed_during_search"
|
|
317
|
+
: "stable";
|
|
318
|
+
const boundedIgnored = boundedValues(
|
|
319
|
+
startIgnoredPaths,
|
|
320
|
+
MAX_AUDIT_IGNORED_FILES,
|
|
321
|
+
MAX_AUDIT_SAMPLE_BYTES,
|
|
322
|
+
);
|
|
323
|
+
const ignoredFileSamples = boundedIgnored.values;
|
|
324
|
+
const ignoredFilesOmitted = boundedIgnored.omitted;
|
|
325
|
+
const boundedChanged = boundedValues(changed, MAX_AUDIT_CHANGED_FILES, MAX_AUDIT_SAMPLE_BYTES);
|
|
326
|
+
const receipt = {
|
|
327
|
+
declaredScope: scope,
|
|
328
|
+
coverage: {
|
|
329
|
+
filesDiscovered,
|
|
330
|
+
filesAdmitted,
|
|
331
|
+
filesSearched: searchedFiles,
|
|
332
|
+
ignoredFiles: startIgnoredPaths.length,
|
|
333
|
+
ignoredFileSamples,
|
|
334
|
+
ignoredFilesOmitted,
|
|
335
|
+
filesSkippedOther,
|
|
336
|
+
complete: closureComplete,
|
|
337
|
+
reasons,
|
|
338
|
+
reasonsOmitted,
|
|
339
|
+
},
|
|
340
|
+
stability: {
|
|
341
|
+
status: stabilityStatus,
|
|
342
|
+
changedFiles: boundedChanged.values,
|
|
343
|
+
changedFilesOmitted: boundedChanged.omitted,
|
|
344
|
+
},
|
|
345
|
+
findings,
|
|
346
|
+
};
|
|
347
|
+
const text = boundedAuditText(
|
|
348
|
+
[
|
|
349
|
+
`Audit receipt (${closureComplete ? "complete" : "PARTIAL"}).`,
|
|
350
|
+
`Scope: ${JSON.stringify(scope)}.`,
|
|
351
|
+
`Coverage: ${String(filesDiscovered)} discovered, ${String(filesAdmitted)} admitted, ${String(searchedFiles)} searched, ${String(startIgnoredPaths.length)} ignored, ${String(filesSkippedOther)} otherwise skipped.`,
|
|
352
|
+
`Stability: ${stabilityStatus}${changed.length ? `; ${String(changed.length)} changed file(s)` : ""}.`,
|
|
353
|
+
"Findings:",
|
|
354
|
+
...findings.map(findingText),
|
|
355
|
+
],
|
|
356
|
+
[
|
|
357
|
+
...(ignoredFileSamples.length
|
|
358
|
+
? [
|
|
359
|
+
`Ignored file samples: ${ignoredFileSamples.map((path) => JSON.stringify(path)).join(", ")}${ignoredFilesOmitted ? `; ${String(ignoredFilesOmitted)} more omitted` : ""}.`,
|
|
360
|
+
]
|
|
361
|
+
: []),
|
|
362
|
+
...(boundedChanged.values.length
|
|
363
|
+
? [
|
|
364
|
+
`Changed file samples: ${boundedChanged.values.map((path) => JSON.stringify(path)).join(", ")}${boundedChanged.omitted ? `; ${String(boundedChanged.omitted)} more omitted` : ""}.`,
|
|
365
|
+
]
|
|
366
|
+
: []),
|
|
367
|
+
...(reasons.length || reasonsOmitted
|
|
368
|
+
? [
|
|
369
|
+
reasons.length
|
|
370
|
+
? `Reasons: ${reasons.map((reason) => JSON.stringify(reason)).join("; ")}${reasonsOmitted ? `; ${String(reasonsOmitted)} more omitted` : ""}.`
|
|
371
|
+
: `Reasons omitted by receipt budget: ${String(reasonsOmitted)}.`,
|
|
372
|
+
]
|
|
373
|
+
: []),
|
|
374
|
+
],
|
|
375
|
+
);
|
|
376
|
+
return {
|
|
377
|
+
text,
|
|
378
|
+
details: {
|
|
379
|
+
version: 1,
|
|
380
|
+
mode: "audit",
|
|
381
|
+
status: closureComplete ? "complete" : "partial",
|
|
382
|
+
totalMatches: scans.reduce((sum, scan) => sum + scan.totalMatches, 0),
|
|
383
|
+
storedMatches: scans.reduce((sum, scan) => sum + scan.matches.length, 0),
|
|
384
|
+
totalFiles: startFiles.paths.length,
|
|
385
|
+
returnedMatches: findings.reduce((sum, finding) => sum + finding.evidence.length, 0),
|
|
386
|
+
snapshotComplete: closureComplete,
|
|
387
|
+
scope,
|
|
388
|
+
audit: receipt,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { MAX_PROTOCOL_LINE_BYTES } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const MAX_DIAGNOSTIC_PREFIX_BYTES = 8 * 1024;
|
|
4
|
+
|
|
5
|
+
export interface CappedLineReaderOptions {
|
|
6
|
+
maxLineBytes?: number;
|
|
7
|
+
onLineTooLong?: (details: { prefix: string; observedBytes: number }) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Consume LF-delimited UTF-8 lines without allowing one unterminated line to
|
|
12
|
+
* grow without a bound. Only LF is treated as a delimiter so U+2028/U+2029
|
|
13
|
+
* inside JSON strings remain ordinary data.
|
|
14
|
+
*/
|
|
15
|
+
export async function consumeCappedLines(
|
|
16
|
+
stream: AsyncIterable<Uint8Array>,
|
|
17
|
+
onLine: (line: string) => void,
|
|
18
|
+
options: CappedLineReaderOptions = {},
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const maxLineBytes = options.maxLineBytes ?? MAX_PROTOCOL_LINE_BYTES;
|
|
21
|
+
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1)
|
|
22
|
+
throw new Error("Capped line byte limit must be a positive safe integer");
|
|
23
|
+
|
|
24
|
+
let lineChunks: Uint8Array[] = [];
|
|
25
|
+
let lineBytes = 0;
|
|
26
|
+
let discarding = false;
|
|
27
|
+
|
|
28
|
+
const resetLine = () => {
|
|
29
|
+
lineChunks = [];
|
|
30
|
+
lineBytes = 0;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const lastLineByte = (): number | undefined => {
|
|
34
|
+
const chunk = lineChunks.at(-1);
|
|
35
|
+
return chunk && chunk.length > 0 ? chunk[chunk.length - 1] : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const prefixFor = (segment: Uint8Array, totalBytes: number): string => {
|
|
39
|
+
const prefixBytes = Math.min(MAX_DIAGNOSTIC_PREFIX_BYTES, totalBytes);
|
|
40
|
+
const prefix = Buffer.allocUnsafe(prefixBytes);
|
|
41
|
+
let copied = 0;
|
|
42
|
+
for (const chunk of lineChunks) {
|
|
43
|
+
if (copied === prefixBytes) break;
|
|
44
|
+
const length = Math.min(chunk.length, prefixBytes - copied);
|
|
45
|
+
prefix.set(chunk.subarray(0, length), copied);
|
|
46
|
+
copied += length;
|
|
47
|
+
}
|
|
48
|
+
if (copied < prefixBytes) {
|
|
49
|
+
const length = Math.min(segment.length, prefixBytes - copied);
|
|
50
|
+
prefix.set(segment.subarray(0, length), copied);
|
|
51
|
+
}
|
|
52
|
+
return prefix.toString("utf8");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const lineText = (withoutTrailingCarriageReturn: boolean): string => {
|
|
56
|
+
const contentBytes = lineBytes - (withoutTrailingCarriageReturn && lineBytes > 0 ? 1 : 0);
|
|
57
|
+
const bytes = Buffer.concat(lineChunks, lineBytes);
|
|
58
|
+
return bytes.toString("utf8", 0, contentBytes);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const reportOverflow = (segment: Uint8Array, observedBytes: number, final: boolean): void => {
|
|
62
|
+
if (!options.onLineTooLong)
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Input line exceeds the ${String(maxLineBytes)}-byte limit${final ? " at end of stream" : ""}`,
|
|
65
|
+
);
|
|
66
|
+
options.onLineTooLong({
|
|
67
|
+
prefix: prefixFor(segment, observedBytes),
|
|
68
|
+
observedBytes,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const consumeChunk = (chunk: Uint8Array, final: boolean) => {
|
|
73
|
+
let offset = 0;
|
|
74
|
+
while (offset < chunk.length) {
|
|
75
|
+
const newline = chunk.indexOf(10, offset);
|
|
76
|
+
const end = newline >= 0 ? newline : chunk.length;
|
|
77
|
+
const segment = chunk.subarray(offset, end);
|
|
78
|
+
|
|
79
|
+
if (discarding) {
|
|
80
|
+
if (newline < 0) return;
|
|
81
|
+
discarding = false;
|
|
82
|
+
resetLine();
|
|
83
|
+
offset = newline + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const observedBytes = lineBytes + segment.length;
|
|
88
|
+
const hasTrailingCarriageReturn =
|
|
89
|
+
newline >= 0 && observedBytes > 0 && (segment.at(-1) ?? lastLineByte()) === 13;
|
|
90
|
+
const contentBytes = observedBytes - (hasTrailingCarriageReturn ? 1 : 0);
|
|
91
|
+
if (contentBytes > maxLineBytes) {
|
|
92
|
+
reportOverflow(segment, observedBytes, final && newline < 0);
|
|
93
|
+
resetLine();
|
|
94
|
+
if (newline < 0) discarding = true;
|
|
95
|
+
else offset = newline + 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (segment.length > 0) lineChunks.push(segment);
|
|
100
|
+
lineBytes = observedBytes;
|
|
101
|
+
if (newline < 0) return;
|
|
102
|
+
onLine(lineText(hasTrailingCarriageReturn));
|
|
103
|
+
resetLine();
|
|
104
|
+
offset = newline + 1;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for await (const chunk of stream) consumeChunk(chunk, false);
|
|
109
|
+
if (discarding) return;
|
|
110
|
+
consumeChunk(new Uint8Array(), true);
|
|
111
|
+
if (lineBytes > 0) onLine(lineText(false));
|
|
112
|
+
}
|