@inflexa-ai/harness 0.28.0 → 0.29.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/dist/app/data-profile-orientation.d.ts +52 -27
- package/dist/app/data-profile-orientation.js +331 -92
- package/dist/app/data-profile-view.d.ts +70 -0
- package/dist/app/data-profile-view.js +123 -0
- package/dist/app/synthesize-run.js +1 -1
- package/dist/contracts/data-profile.d.ts +276 -25
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/profile-vocabulary.d.ts +103 -0
- package/dist/contracts/profile-vocabulary.js +449 -0
- package/dist/contracts/target-dossier.d.ts +21 -21
- package/dist/execution/artifact-registration.js +9 -0
- package/dist/execution/artifact-registry.d.ts +14 -0
- package/dist/execution/input-signature.d.ts +16 -3
- package/dist/execution/input-signature.js +0 -0
- package/dist/execution/post-step-pipeline.d.ts +6 -4
- package/dist/execution/post-step-pipeline.js +18 -9
- package/dist/execution/staged-input.d.ts +4 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +7 -0
- package/dist/input-scan/clustering.d.ts +63 -0
- package/dist/input-scan/clustering.js +208 -0
- package/dist/input-scan/companions.d.ts +30 -0
- package/dist/input-scan/companions.js +138 -0
- package/dist/input-scan/decoder/container_readout.py +171 -0
- package/dist/input-scan/detect-sets.d.ts +24 -0
- package/dist/input-scan/detect-sets.js +279 -0
- package/dist/input-scan/enrich.d.ts +62 -19
- package/dist/input-scan/enrich.js +132 -187
- package/dist/input-scan/formats.d.ts +13 -5
- package/dist/input-scan/formats.js +13 -5
- package/dist/input-scan/index.d.ts +10 -4
- package/dist/input-scan/index.js +7 -4
- package/dist/input-scan/markers.d.ts +21 -0
- package/dist/input-scan/markers.js +89 -0
- package/dist/input-scan/menu.d.ts +127 -0
- package/dist/input-scan/menu.js +0 -0
- package/dist/input-scan/quarantine.d.ts +17 -0
- package/dist/input-scan/quarantine.js +69 -0
- package/dist/input-scan/readout-budget.d.ts +32 -0
- package/dist/input-scan/readout-budget.js +61 -0
- package/dist/input-scan/readout.d.ts +33 -0
- package/dist/input-scan/readout.js +336 -0
- package/dist/input-scan/scan.d.ts +5 -4
- package/dist/input-scan/scan.js +5 -5
- package/dist/input-scan/set-types.d.ts +209 -0
- package/dist/input-scan/set-types.js +15 -0
- package/dist/input-scan/templates.d.ts +93 -0
- package/dist/input-scan/templates.js +369 -0
- package/dist/input-scan/tokens.d.ts +94 -0
- package/dist/input-scan/tokens.js +267 -0
- package/dist/input-scan/tuning.d.ts +55 -0
- package/dist/input-scan/tuning.js +55 -0
- package/dist/input-scan/types.d.ts +2 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +8 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +3 -2
- package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
- package/dist/prompts/sandbox/data-profiler.js +197 -53
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +10 -7
- package/dist/schemas/data-profile-schemas.d.ts +943 -80
- package/dist/schemas/data-profile-schemas.js +300 -115
- package/dist/state/data-profile.d.ts +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/vector-store.d.ts +13 -0
- package/dist/state/vector-store.js +8 -0
- package/dist/tasks/data-profile-absorb.d.ts +78 -0
- package/dist/tasks/data-profile-absorb.js +285 -0
- package/dist/tasks/data-profile-index.d.ts +34 -25
- package/dist/tasks/data-profile-index.js +160 -59
- package/dist/tasks/data-profile-resolve.d.ts +72 -0
- package/dist/tasks/data-profile-resolve.js +639 -0
- package/dist/tasks/data-profile.d.ts +16 -5
- package/dist/tasks/data-profile.js +270 -131
- package/dist/tools/research/inspect-data-profile.d.ts +48 -22
- package/dist/tools/research/inspect-data-profile.js +73 -54
- package/dist/tools/workspace/scan-inputs.d.ts +4 -4
- package/dist/tools/workspace/scan-inputs.js +13 -14
- package/dist/tools/workspace/workspace-search.d.ts +11 -2
- package/dist/tools/workspace/workspace-search.js +44 -16
- package/dist/workflows/sandbox-step.js +36 -6
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
- package/package.json +5 -3
- package/dist/input-scan/coverage.d.ts +0 -22
- package/dist/input-scan/coverage.js +0 -85
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menu — the bounded projection of a detected-set scan that the profiler agent
|
|
3
|
+
* authors against.
|
|
4
|
+
*
|
|
5
|
+
* It is the briefing's replacement for an enumeration of input paths: a tree of three
|
|
6
|
+
* thousand files costs the same context as a tree of thirty, because every list it draws
|
|
7
|
+
* from is capped and the set list itself is capped again here. A menu past its bound
|
|
8
|
+
* SAYS so — an elided tail is a fact the agent can act on, and a silent truncation is
|
|
9
|
+
* the failure this projection exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here names a group or a dimension. Slot correspondence across sets is reported
|
|
12
|
+
* as measured overlap with its gaps, never as a shared dimension: whether two sets' id
|
|
13
|
+
* slots are the same thing is the agent's determination, recorded as a slot observation.
|
|
14
|
+
*/
|
|
15
|
+
import type { DetectedSet, DetectedSets, LeftoverFiles, QuarantineSummary } from "./set-types.js";
|
|
16
|
+
import type { HeaderReadout } from "./types.js";
|
|
17
|
+
/**
|
|
18
|
+
* Sets the menu lists before the tail folds into a counted line.
|
|
19
|
+
*
|
|
20
|
+
* Forty is far past any tree that groups: a scan producing more has told the agent
|
|
21
|
+
* everything a longer list would, and the aggregate says how much was left out.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MAX_MENU_SETS = 40;
|
|
24
|
+
/** Slot pairs reported, ordered by how much they overlap. */
|
|
25
|
+
export declare const MAX_CORRESPONDENCES = 20;
|
|
26
|
+
/** Values named per side of an overlap, so a near-complete correspondence names its misses. */
|
|
27
|
+
export declare const MAX_CORRESPONDENCE_SAMPLE = 5;
|
|
28
|
+
/**
|
|
29
|
+
* Members listed in full rather than by one example.
|
|
30
|
+
*
|
|
31
|
+
* A set this small is a handful of distinct artifacts, and `memberAnnotations` are keyed
|
|
32
|
+
* by path — an agent shown one example of five cannot write per-member prose for the
|
|
33
|
+
* other four. Past the bound the example plus the template is the whole of what a listing
|
|
34
|
+
* would add, at a cost that grows with the tree.
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_FULLY_LISTED_SET_MEMBERS = 10;
|
|
37
|
+
/** Slots crossed in one co-occurrence report. Past this the cross product says nothing a reader can hold. */
|
|
38
|
+
export declare const MAX_CROSSED_SLOTS = 3;
|
|
39
|
+
/**
|
|
40
|
+
* How a set's slots vary TOGETHER.
|
|
41
|
+
*
|
|
42
|
+
* Per-slot cardinalities describe the margins; a set of 8 subjects × 3 timepoints could be
|
|
43
|
+
* a full 24-member crossing or 8 members each at one timepoint, and the two are different
|
|
44
|
+
* experimental designs. The margins alone cannot tell them apart.
|
|
45
|
+
*/
|
|
46
|
+
export interface SetCrossing {
|
|
47
|
+
readonly setId: string;
|
|
48
|
+
readonly slotIds: readonly string[];
|
|
49
|
+
readonly observedCombinations: number;
|
|
50
|
+
/** The full cross product, or `null` when it is past counting. */
|
|
51
|
+
readonly possibleCombinations: number | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Measured overlap between two sets' slots, with its gaps.
|
|
55
|
+
*
|
|
56
|
+
* Evidence, not a claim. Two id slots drawing on the same values is what makes a shared
|
|
57
|
+
* dimension plausible; the values present in one and absent from the other are what make
|
|
58
|
+
* it checkable.
|
|
59
|
+
*/
|
|
60
|
+
export interface SlotCorrespondence {
|
|
61
|
+
readonly sets: readonly [string, string];
|
|
62
|
+
readonly slots: readonly [string, string];
|
|
63
|
+
readonly shared: number;
|
|
64
|
+
readonly onlyInFirst: number;
|
|
65
|
+
readonly onlyInSecond: number;
|
|
66
|
+
readonly sharedSample: readonly string[];
|
|
67
|
+
readonly onlyInFirstSample: readonly string[];
|
|
68
|
+
readonly onlyInSecondSample: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
/** The tail the menu did not list, in aggregate. */
|
|
71
|
+
export interface UnlistedSets {
|
|
72
|
+
readonly sets: number;
|
|
73
|
+
readonly members: number;
|
|
74
|
+
readonly files: number;
|
|
75
|
+
readonly totalBytes: number;
|
|
76
|
+
}
|
|
77
|
+
export interface SetMenu {
|
|
78
|
+
readonly fileCount: number;
|
|
79
|
+
readonly keptFileCount: number;
|
|
80
|
+
/** The addressable entries. An operation may name one of these ids and no other. */
|
|
81
|
+
readonly sets: readonly DetectedSet[];
|
|
82
|
+
readonly unlisted: UnlistedSets;
|
|
83
|
+
readonly correspondences: readonly SlotCorrespondence[];
|
|
84
|
+
/** How each multi-slot set's slots vary together. Empty for single-slot sets. */
|
|
85
|
+
readonly crossings: readonly SetCrossing[];
|
|
86
|
+
readonly quarantine: QuarantineSummary;
|
|
87
|
+
readonly leftovers: LeftoverFiles;
|
|
88
|
+
/** Header readouts keyed by path — one member per set, plus every leftover. */
|
|
89
|
+
readonly headers: ReadonlyMap<string, HeaderReadout>;
|
|
90
|
+
/**
|
|
91
|
+
* True when the walk stopped at its file ceiling. Every count below then describes a
|
|
92
|
+
* prefix of the tree — carried on the menu because the agent's grouping, the persisted
|
|
93
|
+
* census, and the orientation all read as complete otherwise.
|
|
94
|
+
*/
|
|
95
|
+
readonly truncated: boolean;
|
|
96
|
+
/** Leftovers whose header readout was elided by the readout budget. */
|
|
97
|
+
readonly readoutsElided: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Slot overlap across the listed sets, strongest first.
|
|
101
|
+
*
|
|
102
|
+
* Computed from the complete value sets the scan keeps host-side — a bounded sample
|
|
103
|
+
* could not tell a full correspondence from a partial one, which is the whole content of
|
|
104
|
+
* this observation.
|
|
105
|
+
*/
|
|
106
|
+
export declare function buildCorrespondences(detected: DetectedSets, sets: readonly DetectedSet[]): SlotCorrespondence[];
|
|
107
|
+
/**
|
|
108
|
+
* How the slots of one set vary together, from the per-member values the scan keeps
|
|
109
|
+
* host-side. A bounded sample could not distinguish a full crossing from a partial one,
|
|
110
|
+
* which is the whole content of the observation.
|
|
111
|
+
*/
|
|
112
|
+
export declare function buildCrossings(detected: DetectedSets, sets: readonly DetectedSet[]): SetCrossing[];
|
|
113
|
+
export interface BuildSetMenuOptions {
|
|
114
|
+
readonly headers?: ReadonlyMap<string, HeaderReadout>;
|
|
115
|
+
/** The walk stopped at its ceiling; see {@link SetMenu.truncated}. */
|
|
116
|
+
readonly truncated?: boolean;
|
|
117
|
+
readonly readoutsElided?: number;
|
|
118
|
+
}
|
|
119
|
+
/** Project a scan into the bounded menu the agent authors against. */
|
|
120
|
+
export declare function buildSetMenu(detected: DetectedSets, options?: BuildSetMenuOptions): SetMenu;
|
|
121
|
+
/**
|
|
122
|
+
* Render the menu for a briefing or a tool result.
|
|
123
|
+
*
|
|
124
|
+
* The wording states observations only: it never calls a set a group, and it says
|
|
125
|
+
* outright that the grouping decision belongs to the reader.
|
|
126
|
+
*/
|
|
127
|
+
export declare function renderSetMenu(menu: SetMenu, root: string): string;
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 0 — junk and partial artifacts leave before structure is observed.
|
|
3
|
+
*
|
|
4
|
+
* A partial-download twin sitting beside its completed file has the completed file's
|
|
5
|
+
* stem, so template mining absorbs it into the set and the set's member count then
|
|
6
|
+
* counts a file that holds nothing. The exclusion has to happen first, and it has to
|
|
7
|
+
* be reported: a wrongly quarantined file that no one can see is worse than the junk.
|
|
8
|
+
*/
|
|
9
|
+
import type { QuarantineReason, QuarantineSummary } from "./set-types.js";
|
|
10
|
+
import type { ScannedFile } from "./types.js";
|
|
11
|
+
/** The rule a path trips, or `undefined` when it trips none. */
|
|
12
|
+
export declare function quarantineReason(path: string): QuarantineReason | undefined;
|
|
13
|
+
export interface QuarantineResult {
|
|
14
|
+
readonly kept: readonly ScannedFile[];
|
|
15
|
+
readonly summary: QuarantineSummary;
|
|
16
|
+
}
|
|
17
|
+
export declare function quarantine(files: readonly ScannedFile[]): QuarantineResult;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 0 — junk and partial artifacts leave before structure is observed.
|
|
3
|
+
*
|
|
4
|
+
* A partial-download twin sitting beside its completed file has the completed file's
|
|
5
|
+
* stem, so template mining absorbs it into the set and the set's member count then
|
|
6
|
+
* counts a file that holds nothing. The exclusion has to happen first, and it has to
|
|
7
|
+
* be reported: a wrongly quarantined file that no one can see is worse than the junk.
|
|
8
|
+
*/
|
|
9
|
+
import { MAX_QUARANTINE_SAMPLE } from "./tuning.js";
|
|
10
|
+
const EXACT_JUNK_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
11
|
+
const NAME_RULES = [
|
|
12
|
+
{ pattern: /^\._/, reason: "os-junk" },
|
|
13
|
+
{ pattern: /^~\$/, reason: "editor-temp" },
|
|
14
|
+
{ pattern: /~$/, reason: "editor-temp" },
|
|
15
|
+
{ pattern: /^\.~lock\..*#$/, reason: "editor-temp" },
|
|
16
|
+
{ pattern: /\.swp$/, reason: "editor-temp" },
|
|
17
|
+
{ pattern: /\.part$/, reason: "partial-download" },
|
|
18
|
+
{ pattern: /\.filepart$/, reason: "partial-download" },
|
|
19
|
+
{ pattern: /\.crdownload$/, reason: "partial-download" },
|
|
20
|
+
{ pattern: /\.download$/, reason: "partial-download" },
|
|
21
|
+
{ pattern: /\.tmp$/, reason: "atomic-write-temp" },
|
|
22
|
+
{ pattern: /^\.nfs/, reason: "atomic-write-temp" },
|
|
23
|
+
{ pattern: /\.tmp-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, reason: "atomic-write-temp" },
|
|
24
|
+
{ pattern: /\.tmp-[0-9a-fA-F]{8,}$/, reason: "atomic-write-temp" },
|
|
25
|
+
];
|
|
26
|
+
const SEGMENT_RULES = [
|
|
27
|
+
{ pattern: /^__MACOSX$/, reason: "os-junk" },
|
|
28
|
+
{ pattern: /^\.Trash-/, reason: "os-junk" },
|
|
29
|
+
];
|
|
30
|
+
/** The rule a path trips, or `undefined` when it trips none. */
|
|
31
|
+
export function quarantineReason(path) {
|
|
32
|
+
const segments = path.split("/");
|
|
33
|
+
const name = segments[segments.length - 1];
|
|
34
|
+
if (EXACT_JUNK_NAMES.has(name))
|
|
35
|
+
return "os-junk";
|
|
36
|
+
for (const rule of NAME_RULES)
|
|
37
|
+
if (rule.pattern.test(name))
|
|
38
|
+
return rule.reason;
|
|
39
|
+
for (const segment of segments)
|
|
40
|
+
for (const rule of SEGMENT_RULES)
|
|
41
|
+
if (rule.pattern.test(segment))
|
|
42
|
+
return rule.reason;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
export function quarantine(files) {
|
|
46
|
+
const kept = [];
|
|
47
|
+
const excluded = [];
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const reason = quarantineReason(file.path);
|
|
50
|
+
if (reason)
|
|
51
|
+
excluded.push({ file, reason });
|
|
52
|
+
else
|
|
53
|
+
kept.push(file);
|
|
54
|
+
}
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
for (const entry of excluded)
|
|
57
|
+
counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
58
|
+
return {
|
|
59
|
+
kept,
|
|
60
|
+
summary: {
|
|
61
|
+
count: excluded.length,
|
|
62
|
+
totalBytes: excluded.reduce((total, entry) => total + entry.file.size, 0),
|
|
63
|
+
reasons: [...counts.entries()]
|
|
64
|
+
.map(([reason, count]) => ({ reason, count }))
|
|
65
|
+
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason, "en")),
|
|
66
|
+
sample: excluded.slice(0, MAX_QUARANTINE_SAMPLE).map((entry) => entry.file.path),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files a header readout should open.
|
|
3
|
+
*
|
|
4
|
+
* Members of a set are alike by construction — that is what made them a set — so one
|
|
5
|
+
* readout per set establishes what a readout per member would, at a cost that does not
|
|
6
|
+
* grow with the tree. What a set does not speak for still needs reading individually:
|
|
7
|
+
* a leftover is a file nothing else is like, and there is nothing to generalise from.
|
|
8
|
+
*
|
|
9
|
+
* The selection is data, not an action. Deciding to run it, and where, belongs to the
|
|
10
|
+
* caller.
|
|
11
|
+
*/
|
|
12
|
+
import type { DetectedSet, DetectedSets, MemberFile, ReadoutSelection } from "./set-types.js";
|
|
13
|
+
/**
|
|
14
|
+
* Leftovers opened individually before the rest are elided.
|
|
15
|
+
*
|
|
16
|
+
* One readout per set costs O(sets), which is bounded by the menu. Leftovers are not: a
|
|
17
|
+
* tree of one-off human-named files is leftovers all the way down, and reading every one
|
|
18
|
+
* of them is a per-file cost in a pipeline whose whole claim is that it has none.
|
|
19
|
+
*/
|
|
20
|
+
export declare const MAX_LEFTOVER_READOUTS = 64;
|
|
21
|
+
/** A file the selection named, with what the scan already knows about its bytes. */
|
|
22
|
+
export interface ReadoutTarget {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly format: string;
|
|
25
|
+
readonly wrapper?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function selectReadouts(sets: readonly DetectedSet[], leftovers: readonly MemberFile[]): ReadoutSelection;
|
|
28
|
+
/**
|
|
29
|
+
* The selection as readout targets. The format the scan detected rides along, so the
|
|
30
|
+
* reader picks a decoder without re-sniffing bytes it already sniffed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readoutTargets(detected: DetectedSets): ReadoutTarget[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files a header readout should open.
|
|
3
|
+
*
|
|
4
|
+
* Members of a set are alike by construction — that is what made them a set — so one
|
|
5
|
+
* readout per set establishes what a readout per member would, at a cost that does not
|
|
6
|
+
* grow with the tree. What a set does not speak for still needs reading individually:
|
|
7
|
+
* a leftover is a file nothing else is like, and there is nothing to generalise from.
|
|
8
|
+
*
|
|
9
|
+
* The selection is data, not an action. Deciding to run it, and where, belongs to the
|
|
10
|
+
* caller.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Leftovers opened individually before the rest are elided.
|
|
14
|
+
*
|
|
15
|
+
* One readout per set costs O(sets), which is bounded by the menu. Leftovers are not: a
|
|
16
|
+
* tree of one-off human-named files is leftovers all the way down, and reading every one
|
|
17
|
+
* of them is a per-file cost in a pipeline whose whole claim is that it has none.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_LEFTOVER_READOUTS = 64;
|
|
20
|
+
/** The member a set is best read through: the first non-empty one, by path order. */
|
|
21
|
+
function representativeOf(set) {
|
|
22
|
+
const ordered = [...set.members].sort((a, b) => a.path.localeCompare(b.path, "en"));
|
|
23
|
+
return (ordered.find((member) => member.size > 0) ?? ordered[0])?.path;
|
|
24
|
+
}
|
|
25
|
+
export function selectReadouts(sets, leftovers) {
|
|
26
|
+
const representatives = [];
|
|
27
|
+
for (const set of sets) {
|
|
28
|
+
const path = representativeOf(set);
|
|
29
|
+
if (path !== undefined)
|
|
30
|
+
representatives.push({ setId: set.id, path });
|
|
31
|
+
}
|
|
32
|
+
const paths = [...leftovers].map((file) => file.path).sort((a, b) => a.localeCompare(b, "en"));
|
|
33
|
+
return {
|
|
34
|
+
representatives,
|
|
35
|
+
individual: paths.slice(0, MAX_LEFTOVER_READOUTS),
|
|
36
|
+
individualElided: Math.max(0, paths.length - MAX_LEFTOVER_READOUTS),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The selection as readout targets. The format the scan detected rides along, so the
|
|
41
|
+
* reader picks a decoder without re-sniffing bytes it already sniffed.
|
|
42
|
+
*/
|
|
43
|
+
export function readoutTargets(detected) {
|
|
44
|
+
const members = new Map();
|
|
45
|
+
for (const set of detected.sets)
|
|
46
|
+
for (const member of set.members)
|
|
47
|
+
members.set(member.path, member);
|
|
48
|
+
for (const member of detected.leftoverMembers)
|
|
49
|
+
members.set(member.path, member);
|
|
50
|
+
const paths = [...detected.readout.representatives.map((entry) => entry.path), ...detected.readout.individual];
|
|
51
|
+
const targets = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
const member = members.get(path);
|
|
55
|
+
if (!member || seen.has(path))
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(path);
|
|
58
|
+
targets.push({ path, format: member.format, ...(member.wrapper ? { wrapper: member.wrapper } : {}) });
|
|
59
|
+
}
|
|
60
|
+
return targets;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix-sufficient header readouts.
|
|
3
|
+
*
|
|
4
|
+
* Most of what a header says sits at the front of the file: the magic bytes, a
|
|
5
|
+
* declared `##` preamble, a delimited header row, the first records. Reading that
|
|
6
|
+
* costs one bounded prefix and no parser over the whole file, so it runs here in
|
|
7
|
+
* the harness process rather than in a container.
|
|
8
|
+
*
|
|
9
|
+
* Every function below is pure over a `Buffer` — the I/O is the caller's (see
|
|
10
|
+
* `enrich.ts`), which is what makes the whole surface testable from synthetic bytes.
|
|
11
|
+
*/
|
|
12
|
+
/** Bytes read from each file. The readout's whole per-file I/O budget. */
|
|
13
|
+
export declare const READOUT_PREFIX_BYTES = 262144;
|
|
14
|
+
/** Cap on the text window a readout inspects, whether decompressed or raw. */
|
|
15
|
+
export declare const READOUT_TEXT_BYTES = 262144;
|
|
16
|
+
export type ReadoutFields = Record<string, string | number | boolean>;
|
|
17
|
+
export interface PrefixReadout {
|
|
18
|
+
readonly fields: ReadoutFields;
|
|
19
|
+
/** Why the fields are thin or empty. Present alongside whatever WAS read. */
|
|
20
|
+
readonly unavailable?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read what a bounded prefix can say about a file.
|
|
24
|
+
*
|
|
25
|
+
* `wrapper` is the compression the scan already identified from these same bytes
|
|
26
|
+
* (see `formats.ts`); it is reported alongside the inner readout, never in place
|
|
27
|
+
* of it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readPrefix(args: {
|
|
30
|
+
readonly prefix: Buffer;
|
|
31
|
+
readonly format: string;
|
|
32
|
+
readonly wrapper?: string;
|
|
33
|
+
}): Promise<PrefixReadout>;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix-sufficient header readouts.
|
|
3
|
+
*
|
|
4
|
+
* Most of what a header says sits at the front of the file: the magic bytes, a
|
|
5
|
+
* declared `##` preamble, a delimited header row, the first records. Reading that
|
|
6
|
+
* costs one bounded prefix and no parser over the whole file, so it runs here in
|
|
7
|
+
* the harness process rather than in a container.
|
|
8
|
+
*
|
|
9
|
+
* Every function below is pure over a `Buffer` — the I/O is the caller's (see
|
|
10
|
+
* `enrich.ts`), which is what makes the whole surface testable from synthetic bytes.
|
|
11
|
+
*/
|
|
12
|
+
import { constants as zlibConstants, createGunzip, createZstdDecompress } from "node:zlib";
|
|
13
|
+
/** Bytes read from each file. The readout's whole per-file I/O budget. */
|
|
14
|
+
export const READOUT_PREFIX_BYTES = 262_144;
|
|
15
|
+
/** Cap on the text window a readout inspects, whether decompressed or raw. */
|
|
16
|
+
export const READOUT_TEXT_BYTES = 262_144;
|
|
17
|
+
/** Header lines scanned for a format's declared metadata. */
|
|
18
|
+
const MAX_META_LINES = 200;
|
|
19
|
+
const MAX_COLUMNS_REPORTED = 12;
|
|
20
|
+
const MAX_COLUMN_NAME_CHARS = 24;
|
|
21
|
+
const MAX_FIELD_CHARS = 200;
|
|
22
|
+
const MAX_LINE_CHARS = 120;
|
|
23
|
+
const MAX_RECORD_CHARS = 80;
|
|
24
|
+
/** Window scanned for a NUL byte before the prefix is called binary. */
|
|
25
|
+
const BINARY_SNIFF_BYTES = 4096;
|
|
26
|
+
/**
|
|
27
|
+
* Read what a bounded prefix can say about a file.
|
|
28
|
+
*
|
|
29
|
+
* `wrapper` is the compression the scan already identified from these same bytes
|
|
30
|
+
* (see `formats.ts`); it is reported alongside the inner readout, never in place
|
|
31
|
+
* of it.
|
|
32
|
+
*/
|
|
33
|
+
export async function readPrefix(args) {
|
|
34
|
+
const { prefix, format, wrapper } = args;
|
|
35
|
+
if (prefix.length === 0)
|
|
36
|
+
return { fields: {}, unavailable: "file is empty" };
|
|
37
|
+
if (wrapper === undefined)
|
|
38
|
+
return decodeText(prefix, format);
|
|
39
|
+
const inner = await decompressBounded(prefix, wrapper);
|
|
40
|
+
if (inner === undefined)
|
|
41
|
+
return { fields: { wrapper }, unavailable: `${wrapper} has no in-process prefix decoder` };
|
|
42
|
+
if (inner.length === 0)
|
|
43
|
+
return { fields: { wrapper }, unavailable: `${wrapper} prefix did not decompress` };
|
|
44
|
+
return withWrapper(containerFields(inner) ?? decodeText(inner, format), wrapper);
|
|
45
|
+
}
|
|
46
|
+
function withWrapper(readout, wrapper) {
|
|
47
|
+
return { ...readout, fields: { ...readout.fields, wrapper } };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Decompress a prefix, tolerating the truncated tail that a prefix always has.
|
|
51
|
+
*
|
|
52
|
+
* `Z_SYNC_FLUSH` as the finish flush is what makes the cut-off member yield its
|
|
53
|
+
* decoded bytes instead of an error, and the output cap is what keeps a highly
|
|
54
|
+
* compressible prefix from expanding without bound.
|
|
55
|
+
*/
|
|
56
|
+
async function decompressBounded(raw, wrapper) {
|
|
57
|
+
const stream = decompressorFor(wrapper);
|
|
58
|
+
if (stream === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
return await new Promise((resolve) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = () => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
stream.removeAllListeners();
|
|
69
|
+
stream.destroy();
|
|
70
|
+
resolve(Buffer.concat(chunks).subarray(0, READOUT_TEXT_BYTES));
|
|
71
|
+
};
|
|
72
|
+
stream.on("data", (chunk) => {
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
total += chunk.length;
|
|
75
|
+
if (total >= READOUT_TEXT_BYTES)
|
|
76
|
+
finish();
|
|
77
|
+
});
|
|
78
|
+
stream.on("end", finish);
|
|
79
|
+
stream.on("close", finish);
|
|
80
|
+
stream.on("error", finish);
|
|
81
|
+
stream.end(raw);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function decompressorFor(wrapper) {
|
|
85
|
+
if (wrapper === "gzip" || wrapper === "bgzip")
|
|
86
|
+
return createGunzip({ finishFlush: zlibConstants.Z_SYNC_FLUSH });
|
|
87
|
+
if (wrapper === "zstd")
|
|
88
|
+
return createZstdDecompress();
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The two bioinformatics containers whose payload is binary but whose header is a
|
|
93
|
+
* plain text block at a known offset — so a prefix reaches them and no container
|
|
94
|
+
* parser is needed.
|
|
95
|
+
*/
|
|
96
|
+
function containerFields(inner) {
|
|
97
|
+
if (inner.length >= 8 && inner.subarray(0, 4).toString("latin1") === "BAM\u0001") {
|
|
98
|
+
const textLength = inner.readInt32LE(4);
|
|
99
|
+
const header = inner.subarray(8, 8 + Math.min(Math.max(textLength, 0), READOUT_TEXT_BYTES)).toString("utf8");
|
|
100
|
+
const readout = textFields(header, "sam");
|
|
101
|
+
return { ...readout, fields: { ...readout.fields, container: "bam" } };
|
|
102
|
+
}
|
|
103
|
+
if (inner.subarray(0, 3).toString("latin1") === "BCF")
|
|
104
|
+
return { fields: { container: "bcf" } };
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function decodeText(buf, format) {
|
|
108
|
+
if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
|
|
109
|
+
return { fields: {}, unavailable: "binary content with no prefix-readable header" };
|
|
110
|
+
return textFields(buf.subarray(0, READOUT_TEXT_BYTES).toString("utf8"), format);
|
|
111
|
+
}
|
|
112
|
+
function textFields(text, format) {
|
|
113
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
114
|
+
const head = lines[0];
|
|
115
|
+
if (head === undefined)
|
|
116
|
+
return { fields: {}, unavailable: "no text in the prefix" };
|
|
117
|
+
if (format === "markdown")
|
|
118
|
+
return markdownFields(lines);
|
|
119
|
+
if (format === "json" || head.startsWith("{") || head.startsWith("["))
|
|
120
|
+
return jsonFields(text, lines);
|
|
121
|
+
if (format === "yaml" || head.trim() === "---")
|
|
122
|
+
return yamlFields(lines);
|
|
123
|
+
if (head.startsWith("##"))
|
|
124
|
+
return metaHeaderFields(lines);
|
|
125
|
+
if (head.startsWith(">"))
|
|
126
|
+
return { fields: { firstRecord: clip(head.slice(1), MAX_RECORD_CHARS), recordsInPrefix: countPrefixed(lines, ">") } };
|
|
127
|
+
if (head.startsWith("@HD") || head.startsWith("@SQ"))
|
|
128
|
+
return samFields(lines);
|
|
129
|
+
if (head.startsWith("@") && lines.length > 1)
|
|
130
|
+
return { fields: { firstRecord: clip(head.slice(1), MAX_RECORD_CHARS), readLength: lines[1].length } };
|
|
131
|
+
return delimitedFields(lines) ?? { fields: { firstLine: clip(head, MAX_LINE_CHARS), linesInPrefix: lines.length } };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The `##` preamble VCF, GFF, and their relatives declare.
|
|
135
|
+
*
|
|
136
|
+
* Where the preamble carries no column line of its own, the first data line is
|
|
137
|
+
* sniffed as a delimited row: a GFF's nine columns are a fact about the file that
|
|
138
|
+
* a meta-line count alone does not carry.
|
|
139
|
+
*/
|
|
140
|
+
function metaHeaderFields(lines) {
|
|
141
|
+
const fields = {};
|
|
142
|
+
let sawColumns = false;
|
|
143
|
+
for (const line of lines.slice(0, MAX_META_LINES)) {
|
|
144
|
+
if (line.startsWith("##fileformat="))
|
|
145
|
+
fields.fileformat = clip(line.slice("##fileformat=".length), 40);
|
|
146
|
+
else if (line.startsWith("##gff-version"))
|
|
147
|
+
fields.gffVersion = clip(line.slice("##gff-version".length), 20);
|
|
148
|
+
else if (line.startsWith("##reference="))
|
|
149
|
+
fields.reference = clip(line.slice("##reference=".length), 80);
|
|
150
|
+
else if (line.startsWith("#CHROM")) {
|
|
151
|
+
const samples = line.split("\t").slice(9);
|
|
152
|
+
fields.sampleCount = samples.length;
|
|
153
|
+
fields.samples = clip(samples.slice(0, 5).join(", "), MAX_FIELD_CHARS);
|
|
154
|
+
sawColumns = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
fields.metaLines = countPrefixed(lines, "##");
|
|
159
|
+
if (!sawColumns) {
|
|
160
|
+
const delimited = delimitedFields(lines);
|
|
161
|
+
if (delimited)
|
|
162
|
+
return { fields: { ...fields, ...delimited.fields } };
|
|
163
|
+
}
|
|
164
|
+
return { fields };
|
|
165
|
+
}
|
|
166
|
+
function samFields(lines) {
|
|
167
|
+
const fields = {
|
|
168
|
+
referenceSequences: countPrefixed(lines, "@SQ"),
|
|
169
|
+
readGroups: countPrefixed(lines, "@RG"),
|
|
170
|
+
};
|
|
171
|
+
const sortOrder = /(?:^|\t)SO:([^\t]+)/.exec(lines.find((line) => line.startsWith("@HD")) ?? "");
|
|
172
|
+
if (sortOrder)
|
|
173
|
+
fields.sortOrder = clip(sortOrder[1], 20);
|
|
174
|
+
return { fields };
|
|
175
|
+
}
|
|
176
|
+
const DELIMITERS = [
|
|
177
|
+
{ char: ",", name: "comma" },
|
|
178
|
+
{ char: "\t", name: "tab" },
|
|
179
|
+
{ char: ";", name: "semicolon" },
|
|
180
|
+
{ char: "|", name: "pipe" },
|
|
181
|
+
];
|
|
182
|
+
function sniffDelimiter(line) {
|
|
183
|
+
let best;
|
|
184
|
+
let bestCount = 0;
|
|
185
|
+
for (const candidate of DELIMITERS) {
|
|
186
|
+
const count = line.split(candidate.char).length - 1;
|
|
187
|
+
if (count > bestCount) {
|
|
188
|
+
best = candidate;
|
|
189
|
+
bestCount = count;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return best;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The delimited-text readout: which separator, which row carries the column names,
|
|
196
|
+
* and what those names are. The header row is found past any comment preamble, so a
|
|
197
|
+
* `#`-commented TSV reports its columns rather than its first comment line.
|
|
198
|
+
*/
|
|
199
|
+
function delimitedFields(lines) {
|
|
200
|
+
const headerIndex = lines.findIndex((line) => !line.startsWith("#"));
|
|
201
|
+
if (headerIndex < 0)
|
|
202
|
+
return undefined;
|
|
203
|
+
const header = lines[headerIndex];
|
|
204
|
+
const delimiter = sniffDelimiter(header);
|
|
205
|
+
if (delimiter === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
const columns = splitDelimited(header, delimiter.char).map((cell) => cell.trim());
|
|
208
|
+
const fields = {
|
|
209
|
+
delimiter: delimiter.name,
|
|
210
|
+
columnCount: columns.length,
|
|
211
|
+
columns: clip(columns
|
|
212
|
+
.slice(0, MAX_COLUMNS_REPORTED)
|
|
213
|
+
.map((cell) => cell.slice(0, MAX_COLUMN_NAME_CHARS))
|
|
214
|
+
.join(", "), MAX_FIELD_CHARS),
|
|
215
|
+
headerRow: looksLikeHeader(columns),
|
|
216
|
+
linesInPrefix: lines.length,
|
|
217
|
+
};
|
|
218
|
+
if (headerIndex > 0)
|
|
219
|
+
fields.commentLines = headerIndex;
|
|
220
|
+
return { fields };
|
|
221
|
+
}
|
|
222
|
+
/** Distinct, mostly non-numeric cells name columns; a row of numbers is data. */
|
|
223
|
+
function looksLikeHeader(columns) {
|
|
224
|
+
if (columns.length === 0)
|
|
225
|
+
return false;
|
|
226
|
+
const numeric = columns.filter((cell) => cell !== "" && Number.isFinite(Number(cell))).length;
|
|
227
|
+
return new Set(columns).size === columns.length && numeric * 2 < columns.length;
|
|
228
|
+
}
|
|
229
|
+
function splitDelimited(line, delimiter) {
|
|
230
|
+
const cells = [];
|
|
231
|
+
let current = "";
|
|
232
|
+
let quoted = false;
|
|
233
|
+
for (let index = 0; index < line.length; index++) {
|
|
234
|
+
const char = line[index];
|
|
235
|
+
if (quoted) {
|
|
236
|
+
if (char !== '"')
|
|
237
|
+
current += char;
|
|
238
|
+
else if (line[index + 1] === '"') {
|
|
239
|
+
current += '"';
|
|
240
|
+
index++;
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
quoted = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (char === '"')
|
|
247
|
+
quoted = true;
|
|
248
|
+
else if (char === delimiter) {
|
|
249
|
+
cells.push(current);
|
|
250
|
+
current = "";
|
|
251
|
+
}
|
|
252
|
+
else
|
|
253
|
+
current += char;
|
|
254
|
+
}
|
|
255
|
+
cells.push(current);
|
|
256
|
+
return cells;
|
|
257
|
+
}
|
|
258
|
+
function jsonFields(text, lines) {
|
|
259
|
+
const whole = parseJson(text.slice(0, READOUT_TEXT_BYTES));
|
|
260
|
+
if (Array.isArray(whole)) {
|
|
261
|
+
const first = whole[0];
|
|
262
|
+
return {
|
|
263
|
+
fields: {
|
|
264
|
+
jsonType: "array",
|
|
265
|
+
elementCount: whole.length,
|
|
266
|
+
...(isRecord(first) ? { firstElementKeys: joinKeys(Object.keys(first)) } : {}),
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (isRecord(whole)) {
|
|
271
|
+
const keys = Object.keys(whole);
|
|
272
|
+
return { fields: { jsonType: "object", keyCount: keys.length, keys: joinKeys(keys) } };
|
|
273
|
+
}
|
|
274
|
+
if (whole !== undefined)
|
|
275
|
+
return { fields: { jsonType: typeof whole } };
|
|
276
|
+
// A prefix of JSON Lines truncates mid-record at worst once, at the tail.
|
|
277
|
+
const candidates = lines.slice(0, MAX_META_LINES);
|
|
278
|
+
const parsed = candidates.map((line) => parseJson(line));
|
|
279
|
+
const complete = parsed.filter((value) => value !== undefined);
|
|
280
|
+
const record = parsed.find(isRecord);
|
|
281
|
+
if (record !== undefined && complete.length >= Math.max(1, candidates.length - 1)) {
|
|
282
|
+
return { fields: { jsonType: "json-lines", recordsInPrefix: lines.length, keys: joinKeys(Object.keys(record)) } };
|
|
283
|
+
}
|
|
284
|
+
return { fields: { firstLine: clip(lines[0], MAX_LINE_CHARS) }, unavailable: "JSON document extends past the readout prefix" };
|
|
285
|
+
}
|
|
286
|
+
/** Top-level keys, read positionally: an unindented `key:` line opens a mapping entry. */
|
|
287
|
+
function yamlFields(lines) {
|
|
288
|
+
const keys = [];
|
|
289
|
+
for (const line of lines.slice(0, MAX_META_LINES)) {
|
|
290
|
+
const match = /^([A-Za-z_][\w.-]*):(\s|$)/.exec(line);
|
|
291
|
+
if (match)
|
|
292
|
+
keys.push(match[1]);
|
|
293
|
+
}
|
|
294
|
+
const documents = lines.filter((line) => line.trim() === "---").length;
|
|
295
|
+
return {
|
|
296
|
+
fields: {
|
|
297
|
+
topLevelKeyCount: keys.length,
|
|
298
|
+
topLevelKeys: joinKeys(keys),
|
|
299
|
+
linesInPrefix: lines.length,
|
|
300
|
+
...(documents > 0 ? { documentSeparators: documents } : {}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function markdownFields(lines) {
|
|
305
|
+
const headings = lines.filter((line) => /^#{1,6}\s/.test(line));
|
|
306
|
+
return {
|
|
307
|
+
fields: {
|
|
308
|
+
title: clip((headings[0] ?? lines[0]).replace(/^#+\s*/, ""), MAX_LINE_CHARS),
|
|
309
|
+
headings: headings.length,
|
|
310
|
+
linesInPrefix: lines.length,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function parseJson(text) {
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(text);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function isRecord(value) {
|
|
323
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
324
|
+
}
|
|
325
|
+
function joinKeys(keys) {
|
|
326
|
+
return clip(keys
|
|
327
|
+
.slice(0, MAX_COLUMNS_REPORTED)
|
|
328
|
+
.map((key) => key.slice(0, MAX_COLUMN_NAME_CHARS))
|
|
329
|
+
.join(", "), MAX_FIELD_CHARS);
|
|
330
|
+
}
|
|
331
|
+
function countPrefixed(lines, prefix) {
|
|
332
|
+
return lines.filter((line) => line.startsWith(prefix)).length;
|
|
333
|
+
}
|
|
334
|
+
function clip(text, limit) {
|
|
335
|
+
return text.replace(/\s+/g, " ").trim().slice(0, limit);
|
|
336
|
+
}
|