@deepwatch/dsh-contracts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Language-aware evidence.
3
+ *
4
+ * The correction this module encodes (spec §37): Watch is not an English
5
+ * product with translations bolted on, and it is not an Arabic one either. It
6
+ * is language-independent architecture with a *measured* support matrix — and
7
+ * the two claims are very different. "Handles Unicode" is architecture.
8
+ * "Reads Thai subtitles well" is a measurement, and it belongs to a
9
+ * qualification result rather than to a type.
10
+ *
11
+ * Five things are kept separate that products routinely conflate:
12
+ *
13
+ * ```
14
+ * UI locale what the interface is in
15
+ * source language(s) what the material is in
16
+ * script(s) what alphabet it is written in
17
+ * response language what the agent answers in
18
+ * translation target an optional derived view
19
+ * ```
20
+ *
21
+ * Changing the interface language must not translate evidence. A source can
22
+ * contain several languages in one frame. And a translation is *derived data
23
+ * with its own provenance* — the original text stays the evidence, because a
24
+ * citation that resolved to a translation would be citing something no one
25
+ * ever said.
26
+ *
27
+ * @module @deepwatch/dsh-contracts/language
28
+ */
29
+ /** Scripts that are written right to left. */
30
+ const RTL_SCRIPTS = new Set(['Arabic', 'Hebrew']);
31
+ /**
32
+ * Detect the scripts present in a string.
33
+ *
34
+ * Range-based rather than library-based on purpose: this runs on the browser
35
+ * side of an evidence panel, and pulling a full ICU table into a bundle to
36
+ * answer "is there Arabic in here" is not a trade worth making. It is
37
+ * deliberately coarse — it reports which scripts appear, which is what
38
+ * direction and routing need, not a language identification.
39
+ */
40
+ export function detectScripts(text) {
41
+ const found = new Set();
42
+ for (const character of text) {
43
+ const code = character.codePointAt(0);
44
+ if (code === undefined)
45
+ continue;
46
+ // Skip anything that carries no script identity: digits, punctuation,
47
+ // whitespace. Counting them would make every string "Latin".
48
+ if (code < 0x0041)
49
+ continue;
50
+ if (code <= 0x024f)
51
+ found.add('Latin');
52
+ else if (code >= 0x0370 && code <= 0x03ff)
53
+ found.add('Greek');
54
+ else if (code >= 0x0400 && code <= 0x04ff)
55
+ found.add('Cyrillic');
56
+ else if (code >= 0x0590 && code <= 0x05ff)
57
+ found.add('Hebrew');
58
+ else if (code >= 0x0600 && code <= 0x06ff)
59
+ found.add('Arabic');
60
+ else if (code >= 0x0750 && code <= 0x077f)
61
+ found.add('Arabic');
62
+ else if (code >= 0x0900 && code <= 0x097f)
63
+ found.add('Devanagari');
64
+ else if (code >= 0x0e00 && code <= 0x0e7f)
65
+ found.add('Thai');
66
+ else if (code >= 0x0e80 && code <= 0x0eff)
67
+ found.add('Lao');
68
+ else if (code >= 0x0f00 && code <= 0x0fff)
69
+ found.add('Tibetan');
70
+ else if (code >= 0x1000 && code <= 0x109f)
71
+ found.add('Myanmar');
72
+ else if (code >= 0x1780 && code <= 0x17ff)
73
+ found.add('Khmer');
74
+ else if (code >= 0x3040 && code <= 0x309f)
75
+ found.add('Hiragana');
76
+ else if (code >= 0x30a0 && code <= 0x30ff)
77
+ found.add('Katakana');
78
+ else if (code >= 0x4e00 && code <= 0x9fff)
79
+ found.add('Han');
80
+ else if (code >= 0xac00 && code <= 0xd7af)
81
+ found.add('Hangul');
82
+ else if (code >= 0xfb50 && code <= 0xfdff)
83
+ found.add('Arabic');
84
+ else if (code >= 0xfe70 && code <= 0xfeff)
85
+ found.add('Arabic');
86
+ }
87
+ return found.size === 0 ? ['Unknown'] : [...found].sort();
88
+ }
89
+ /**
90
+ * Which way a string reads.
91
+ *
92
+ * `mixed` is a real answer, not a failure to decide. A layout that renders
93
+ * mixed content as though it were uniformly one direction produces text that
94
+ * is technically present and unreadable.
95
+ */
96
+ export function detectDirection(text) {
97
+ const scripts = detectScripts(text);
98
+ if (scripts.length === 1 && scripts[0] === 'Unknown')
99
+ return 'unknown';
100
+ const hasRtl = scripts.some(script => RTL_SCRIPTS.has(script));
101
+ const hasLtr = scripts.some(script => script !== 'Unknown' && !RTL_SCRIPTS.has(script));
102
+ if (hasRtl && hasLtr)
103
+ return 'mixed';
104
+ if (hasRtl)
105
+ return 'rtl';
106
+ return 'ltr';
107
+ }
108
+ /**
109
+ * Normalize text for retrieval.
110
+ *
111
+ * NFKC plus case folding plus combining-mark removal: enough that a query
112
+ * typed without diacritics finds text written with them, and that half-width
113
+ * and full-width forms match. Deliberately not stemming or transliteration —
114
+ * those change what a word means, and an index that matched across them would
115
+ * return hits a person cannot see the reason for.
116
+ */
117
+ export function normalizeForRetrieval(text) {
118
+ return text
119
+ // NFKD, not NFKC. Compatibility *decomposition* is what separates a
120
+ // precomposed É into E plus a combining acute so the next step can remove
121
+ // it; NFKC would compose it back into one codepoint that matches no
122
+ // combining-mark pattern, and the diacritic would survive the fold.
123
+ .normalize('NFKD')
124
+ .toLowerCase()
125
+ // Strip combining marks. This folds Arabic and Hebrew vowel points and
126
+ // Latin accents alike, which is what makes a query match text that was
127
+ // written more carefully than it was typed.
128
+ .replace(/\p{M}+/gu, '')
129
+ // Recompose. NFKD also splits Hangul syllables into jamo, and leaving them
130
+ // apart would mean composed and decomposed Korean no longer match each
131
+ // other — the exact failure this function exists to prevent.
132
+ .normalize('NFC')
133
+ .replace(/\s+/gu, ' ')
134
+ .trim();
135
+ }
136
+ /**
137
+ * Build the language-aware text for an observed span.
138
+ *
139
+ * The normalized form is computed here rather than accepted from a caller, so
140
+ * every index in the product folds text the same way. Two normalizers that
141
+ * disagree produce a search that finds a result in one surface and not in
142
+ * another, for reasons nobody can see.
143
+ */
144
+ export function describeText(originalText, producer, producerVersion, options = {}) {
145
+ return {
146
+ originalText,
147
+ normalizedText: normalizeForRetrieval(originalText),
148
+ languageTags: options.languageTags ?? [],
149
+ scripts: detectScripts(originalText),
150
+ direction: detectDirection(originalText),
151
+ languageConfidence: options.languageConfidence ?? null,
152
+ producer,
153
+ producerVersion,
154
+ translations: [],
155
+ qualityWarnings: options.qualityWarnings ?? [],
156
+ };
157
+ }
158
+ /**
159
+ * Attach a translation without disturbing the original.
160
+ *
161
+ * Returns a new value. The original text is unchanged and stays first, because
162
+ * the moment a translation could overwrite it, a citation would resolve to
163
+ * something nobody said.
164
+ */
165
+ export function withTranslation(text, translation) {
166
+ return { ...text, translations: [...text.translations, translation] };
167
+ }
168
+ /**
169
+ * What a surface should display, given a reader's preference.
170
+ *
171
+ * Returns the original unless a translation for the requested language exists,
172
+ * and always says which it returned. A caller that renders this without
173
+ * showing `isOriginal` is presenting derived text as observed text.
174
+ */
175
+ export function displayText(text, preferredLanguage) {
176
+ if (preferredLanguage === null) {
177
+ return { text: text.originalText, isOriginal: true, direction: text.direction };
178
+ }
179
+ const translation = text.translations.find(entry => entry.targetLanguage === preferredLanguage);
180
+ if (translation === undefined) {
181
+ return { text: text.originalText, isOriginal: true, direction: text.direction };
182
+ }
183
+ return {
184
+ text: translation.text,
185
+ isOriginal: false,
186
+ // The translation's own direction, not the original's: rendering Arabic
187
+ // output with the source's ltr direction produces unreadable text.
188
+ direction: detectDirection(translation.text),
189
+ };
190
+ }
191
+ /**
192
+ * Whether a span must be isolated from the surrounding text direction.
193
+ *
194
+ * Code, URLs, identifiers and timestamps read left to right inside an
195
+ * otherwise right-to-left paragraph, and without isolation the bidi algorithm
196
+ * reorders them into something that looks plausible and is wrong — a path with
197
+ * its segments reversed, a timestamp reading 30:2.
198
+ */
199
+ export function needsDirectionIsolation(kind) {
200
+ return ['code', 'url', 'path', 'identifier', 'timestamp', 'digest', 'version'].includes(kind);
201
+ }
202
+ //# sourceMappingURL=language.js.map
package/lib/paths.d.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Keeping this machine's directory names out of everything that leaves it.
3
+ *
4
+ * A workspace was selected, and from then on `D:\Ws` appeared in the Context
5
+ * panel, in the session log, and in the text handed to the model — because a
6
+ * workspace is a directory and every layer below the UI quite reasonably
7
+ * carries the directory. None of those places needed the absolute path. The
8
+ * panel needed a name, the log needed something stable to group by, and the
9
+ * model needed to know which files exist relative to a root it never has to
10
+ * name.
11
+ *
12
+ * So this module converts a real path into a *logical* one: `<workspace>/src`
13
+ * rather than `D:\Ws\src`. The logical form is stable, comparable, and says
14
+ * everything the reader needs, and the real path stays on the Host where the
15
+ * filesystem actually is.
16
+ *
17
+ * **Structured, never blanket.** The one thing this must not do is replace
18
+ * substrings across arbitrary text: evidence content, a transcript, a captured
19
+ * page, a user's own message may legitimately contain a string that looks like
20
+ * a path, and rewriting it would corrupt the very record the product exists to
21
+ * preserve. Everything here therefore operates on a *named field* or on a
22
+ * bounded diagnostic string a caller has explicitly identified. There is no
23
+ * function in this file that takes a document and scrubs it.
24
+ *
25
+ * Windows makes the comparison harder than it looks, and each of these is
26
+ * handled rather than hoped about: drive letters differ in case (`D:\` and
27
+ * `d:\` are one directory), separators are mixed within a single string by the
28
+ * time Node and a shell have both touched it, UNC paths have a leading `\\`
29
+ * that is not a separator, and a prefix match without a boundary check makes
30
+ * `D:\Wsuite` look like it is inside `D:\Ws`.
31
+ *
32
+ * @module @deepwatch/dsh-contracts/paths
33
+ */
34
+ /** A local root worth naming rather than printing. */
35
+ export type PathRootKind =
36
+ /** The directory the user chose to work in. */
37
+ 'workspace'
38
+ /** The DeepWatch profile: runtime, receipts, profile state. */
39
+ | 'profile'
40
+ /** The DeepSeek Harness home inside that profile. */
41
+ | 'dsh-home'
42
+ /** A source checkout, present only on a developer's machine. */
43
+ | 'checkout'
44
+ /** The operating system user's home directory. */
45
+ | 'home'
46
+ /** The system temporary directory. */
47
+ | 'temp';
48
+ /** One root, and the label that replaces it. */
49
+ export interface PathRoot {
50
+ readonly kind: PathRootKind;
51
+ /** The real absolute path. Never rendered, never sent. */
52
+ readonly path: string;
53
+ /**
54
+ * What a reader sees instead, without the angle brackets.
55
+ *
56
+ * Defaults to the kind, so `workspace` renders as `<workspace>`. A caller
57
+ * with two workspaces open can pass `workspace:notes` and keep them apart
58
+ * without either becoming a directory name.
59
+ */
60
+ readonly label?: string;
61
+ }
62
+ /** The roots a redaction is measured against, longest first. */
63
+ export type PathRoots = readonly PathRoot[];
64
+ /**
65
+ * Whether a string is an absolute local path in any of the three shapes.
66
+ *
67
+ * Used to decide whether a *field known to hold a path* needs converting. It
68
+ * is deliberately not used to scan prose.
69
+ */
70
+ export declare function isAbsoluteLocalPath(value: string): boolean;
71
+ /**
72
+ * One comparable spelling of a path.
73
+ *
74
+ * Separators become `/`, a trailing separator is dropped, and a drive letter
75
+ * is upper-cased — Windows treats `d:` and `D:` as one directory and a
76
+ * case-sensitive comparison would miss half the matches. The rest of the path
77
+ * keeps its case: on a case-sensitive filesystem `src` and `SRC` are two
78
+ * directories, and folding them would make the redaction wrong in the other
79
+ * direction.
80
+ */
81
+ export declare function normalisePath(value: string): string;
82
+ /**
83
+ * Whether `candidate` is `root` or sits inside it.
84
+ *
85
+ * The separator check is the whole point: a bare `startsWith` reports that
86
+ * `D:\Wsuite` is inside `D:\Ws`, which would redact an unrelated
87
+ * directory and leave the reader with a path that never existed.
88
+ */
89
+ export declare function isInsideRoot(root: string, candidate: string): boolean;
90
+ /**
91
+ * One comparable spelling with `.` and `..` resolved away.
92
+ *
93
+ * Separate from {@link normalisePath} because the two answer different
94
+ * questions. Redaction asks "does this path start with a root I know", and a
95
+ * literal spelling is the right input for that. Containment asks "where does
96
+ * this path actually land", and there the literal spelling is the attack:
97
+ * `<workspace>/../elsewhere/notes.md` starts with the workspace and is not
98
+ * inside it. A containment check that skips this step reports the boundary
99
+ * holding while a call walks straight out of it.
100
+ *
101
+ * A `..` that would climb above the root is dropped rather than escaping into
102
+ * a relative prefix, so the result is always anchored where it started.
103
+ */
104
+ export declare function resolveTraversal(value: string): string;
105
+ /**
106
+ * Whether a path lands inside a root once traversal is resolved.
107
+ *
108
+ * The containment question, and the one a boundary must ask.
109
+ * {@link isInsideRoot} is the redaction question and stays literal; this one
110
+ * resolves first, so `<root>/../elsewhere` is outside and `<root>/./a/../b` is
111
+ * inside.
112
+ *
113
+ * It cannot see a symlink or a junction — that needs the filesystem, and this
114
+ * package is shared with the browser. The Host resolves those before calling
115
+ * this; the two together are the boundary.
116
+ */
117
+ export declare function containsPath(root: string, candidate: string): boolean;
118
+ /**
119
+ * The logical form of a path, or the path unchanged when no root contains it.
120
+ *
121
+ * Roots are tried longest-first so a workspace nested inside a profile is
122
+ * reported as `<workspace>/…` rather than `<profile>/workspace/…`: the more
123
+ * specific name is the more useful one.
124
+ *
125
+ * A path under no known root is returned as it came in. That is deliberate —
126
+ * silently mangling an unrecognised path would make a diagnostic unreadable —
127
+ * and it is why {@link assertNoLocalPath} exists for the surfaces where an
128
+ * unrecognised absolute path must be a failure rather than a passthrough.
129
+ */
130
+ export declare function redactPath(value: string, roots: PathRoots): string;
131
+ /**
132
+ * The path relative to a root, for the places that want no label at all.
133
+ *
134
+ * What a model should be given: `src/index.ts`, not `<workspace>/src/index.ts`
135
+ * and certainly not `D:\Ws\src\index.ts`. Returns null when the path is not
136
+ * inside the root, so a caller cannot accidentally send an absolute path by
137
+ * treating a failed conversion as a success.
138
+ */
139
+ export declare function relativeToRoot(value: string, root: string): string | null;
140
+ /**
141
+ * Redact only the named fields of a record.
142
+ *
143
+ * The safe shape of this operation. A caller says which keys hold paths, and
144
+ * nothing else in the object is examined — so a `content`, a `transcript` or a
145
+ * `message` beside them is carried through byte for byte.
146
+ *
147
+ * @param record - the object to copy.
148
+ * @param fields - the keys whose string values are paths.
149
+ * @param roots - the roots to measure against.
150
+ * @returns a shallow copy with those fields converted.
151
+ */
152
+ export declare function redactFields<T extends Record<string, unknown>>(record: T, fields: readonly (keyof T & string)[], roots: PathRoots): T;
153
+ /**
154
+ * Redact one diagnostic string a caller has identified as path-bearing.
155
+ *
156
+ * Bounded on purpose: it rewrites only complete path tokens, delimited by
157
+ * whitespace, quotes or the common punctuation a path is wrapped in when it
158
+ * lands in a message. It is for a log line or an error detail whose shape the
159
+ * caller knows — never for evidence, a transcript, a captured document or
160
+ * anything a person wrote.
161
+ */
162
+ export declare function redactDiagnosticText(text: string, roots: PathRoots): string;
163
+ /**
164
+ * Every absolute local path a string still contains.
165
+ *
166
+ * The test-facing half. A surface that must never carry one asserts this is
167
+ * empty, and gets the offending text back rather than a bare false.
168
+ *
169
+ * A path preceded by `//` in a URL is skipped: `https://host/home/x` names a
170
+ * server's route, not this machine's disk, and reporting it would be a false
171
+ * alarm in exactly the diagnostics a reader needs.
172
+ *
173
+ * The same false alarm reached the Windows branch by a subtler route, and cost
174
+ * more: a drive letter is one letter followed by a colon, and `https:` ends in
175
+ * exactly that shape. Every ordinary URL was therefore reported as a local
176
+ * path — `s://api.example.com` — so `assertNoLocalPath` threw on honest
177
+ * diagnostics like "failed to reach https://…", which is precisely the kind of
178
+ * guard somebody switches off. The lookbehind is what makes a drive letter a
179
+ * drive letter: nothing alphabetic before it, and no colon before a `//`.
180
+ *
181
+ * `file:///D:/Ws/x` still matches, and should: a file URL carries a real local
182
+ * path, and the `D:` in it is preceded by `/` rather than by a scheme.
183
+ */
184
+ export declare function findAbsolutePaths(text: string): readonly string[];
185
+ /**
186
+ * Throw when a value that must be free of local paths is not.
187
+ *
188
+ * Used at the boundaries where a passthrough would be a leak rather than a
189
+ * convenience: a provider payload, an export, a rendered surface. The message
190
+ * names the field and the offending token so the failure is fixable, and it is
191
+ * a programming error rather than a runtime condition.
192
+ */
193
+ export declare function assertNoLocalPath(where: string, value: string): void;
194
+ //# sourceMappingURL=paths.d.ts.map
package/lib/paths.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Keeping this machine's directory names out of everything that leaves it.
3
+ *
4
+ * A workspace was selected, and from then on `D:\Ws` appeared in the Context
5
+ * panel, in the session log, and in the text handed to the model — because a
6
+ * workspace is a directory and every layer below the UI quite reasonably
7
+ * carries the directory. None of those places needed the absolute path. The
8
+ * panel needed a name, the log needed something stable to group by, and the
9
+ * model needed to know which files exist relative to a root it never has to
10
+ * name.
11
+ *
12
+ * So this module converts a real path into a *logical* one: `<workspace>/src`
13
+ * rather than `D:\Ws\src`. The logical form is stable, comparable, and says
14
+ * everything the reader needs, and the real path stays on the Host where the
15
+ * filesystem actually is.
16
+ *
17
+ * **Structured, never blanket.** The one thing this must not do is replace
18
+ * substrings across arbitrary text: evidence content, a transcript, a captured
19
+ * page, a user's own message may legitimately contain a string that looks like
20
+ * a path, and rewriting it would corrupt the very record the product exists to
21
+ * preserve. Everything here therefore operates on a *named field* or on a
22
+ * bounded diagnostic string a caller has explicitly identified. There is no
23
+ * function in this file that takes a document and scrubs it.
24
+ *
25
+ * Windows makes the comparison harder than it looks, and each of these is
26
+ * handled rather than hoped about: drive letters differ in case (`D:\` and
27
+ * `d:\` are one directory), separators are mixed within a single string by the
28
+ * time Node and a shell have both touched it, UNC paths have a leading `\\`
29
+ * that is not a separator, and a prefix match without a boundary check makes
30
+ * `D:\Wsuite` look like it is inside `D:\Ws`.
31
+ *
32
+ * @module @deepwatch/dsh-contracts/paths
33
+ */
34
+ /** Windows drive-absolute, e.g. `D:\Ws` or `d:/Ws`. */
35
+ const DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/;
36
+ /** UNC, e.g. `\\server\share\dir`. */
37
+ const UNC_ABSOLUTE = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]+/;
38
+ /**
39
+ * Whether a string is an absolute local path in any of the three shapes.
40
+ *
41
+ * Used to decide whether a *field known to hold a path* needs converting. It
42
+ * is deliberately not used to scan prose.
43
+ */
44
+ export function isAbsoluteLocalPath(value) {
45
+ return DRIVE_ABSOLUTE.test(value) || UNC_ABSOLUTE.test(value) || value.startsWith('/');
46
+ }
47
+ /**
48
+ * One comparable spelling of a path.
49
+ *
50
+ * Separators become `/`, a trailing separator is dropped, and a drive letter
51
+ * is upper-cased — Windows treats `d:` and `D:` as one directory and a
52
+ * case-sensitive comparison would miss half the matches. The rest of the path
53
+ * keeps its case: on a case-sensitive filesystem `src` and `SRC` are two
54
+ * directories, and folding them would make the redaction wrong in the other
55
+ * direction.
56
+ */
57
+ export function normalisePath(value) {
58
+ const slashed = value.replace(/\\/g, '/');
59
+ const drive = /^([A-Za-z]):\//.exec(slashed);
60
+ const cased = drive === null
61
+ ? slashed
62
+ : `${drive[1]?.toUpperCase() ?? ''}:/${slashed.slice(3)}`;
63
+ return cased.length > 1 && cased.endsWith('/') ? cased.slice(0, -1) : cased;
64
+ }
65
+ /**
66
+ * Whether `candidate` is `root` or sits inside it.
67
+ *
68
+ * The separator check is the whole point: a bare `startsWith` reports that
69
+ * `D:\Wsuite` is inside `D:\Ws`, which would redact an unrelated
70
+ * directory and leave the reader with a path that never existed.
71
+ */
72
+ export function isInsideRoot(root, candidate) {
73
+ const base = normalisePath(root);
74
+ const target = normalisePath(candidate);
75
+ if (base === target)
76
+ return true;
77
+ // Windows is case-insensitive about the whole path in practice, but only the
78
+ // drive is folded above; comparing the remainder exactly is the safe side of
79
+ // that trade — a missed redaction is caught by tests, a wrong one is not.
80
+ return target.startsWith(base.endsWith('/') ? base : `${base}/`);
81
+ }
82
+ /**
83
+ * One comparable spelling with `.` and `..` resolved away.
84
+ *
85
+ * Separate from {@link normalisePath} because the two answer different
86
+ * questions. Redaction asks "does this path start with a root I know", and a
87
+ * literal spelling is the right input for that. Containment asks "where does
88
+ * this path actually land", and there the literal spelling is the attack:
89
+ * `<workspace>/../elsewhere/notes.md` starts with the workspace and is not
90
+ * inside it. A containment check that skips this step reports the boundary
91
+ * holding while a call walks straight out of it.
92
+ *
93
+ * A `..` that would climb above the root is dropped rather than escaping into
94
+ * a relative prefix, so the result is always anchored where it started.
95
+ */
96
+ export function resolveTraversal(value) {
97
+ const normalised = normalisePath(value);
98
+ const prefix = /^(?:[A-Z]:\/|\/\/[^/]+\/[^/]+\/|\/)/.exec(normalised)?.[0] ?? '';
99
+ const rest = normalised.slice(prefix.length);
100
+ const out = [];
101
+ for (const segment of rest.split('/')) {
102
+ if (segment === '' || segment === '.')
103
+ continue;
104
+ if (segment === '..') {
105
+ out.pop();
106
+ continue;
107
+ }
108
+ out.push(segment);
109
+ }
110
+ const joined = `${prefix}${out.join('/')}`;
111
+ return joined.length > 1 && joined.endsWith('/') ? joined.slice(0, -1) : joined;
112
+ }
113
+ /**
114
+ * Whether a path lands inside a root once traversal is resolved.
115
+ *
116
+ * The containment question, and the one a boundary must ask.
117
+ * {@link isInsideRoot} is the redaction question and stays literal; this one
118
+ * resolves first, so `<root>/../elsewhere` is outside and `<root>/./a/../b` is
119
+ * inside.
120
+ *
121
+ * It cannot see a symlink or a junction — that needs the filesystem, and this
122
+ * package is shared with the browser. The Host resolves those before calling
123
+ * this; the two together are the boundary.
124
+ */
125
+ export function containsPath(root, candidate) {
126
+ return isInsideRoot(resolveTraversal(root), resolveTraversal(candidate));
127
+ }
128
+ /** The label a root renders as, in angle brackets. */
129
+ function labelOf(root) {
130
+ return `<${root.label ?? root.kind}>`;
131
+ }
132
+ /**
133
+ * The logical form of a path, or the path unchanged when no root contains it.
134
+ *
135
+ * Roots are tried longest-first so a workspace nested inside a profile is
136
+ * reported as `<workspace>/…` rather than `<profile>/workspace/…`: the more
137
+ * specific name is the more useful one.
138
+ *
139
+ * A path under no known root is returned as it came in. That is deliberate —
140
+ * silently mangling an unrecognised path would make a diagnostic unreadable —
141
+ * and it is why {@link assertNoLocalPath} exists for the surfaces where an
142
+ * unrecognised absolute path must be a failure rather than a passthrough.
143
+ */
144
+ export function redactPath(value, roots) {
145
+ if (value === '')
146
+ return value;
147
+ const ordered = [...roots].sort((a, b) => normalisePath(b.path).length - normalisePath(a.path).length);
148
+ for (const root of ordered) {
149
+ if (!isInsideRoot(root.path, value))
150
+ continue;
151
+ const base = normalisePath(root.path);
152
+ const target = normalisePath(value);
153
+ const rest = target.slice(base.length).replace(/^\//, '');
154
+ return rest === '' ? labelOf(root) : `${labelOf(root)}/${rest}`;
155
+ }
156
+ return value;
157
+ }
158
+ /**
159
+ * The path relative to a root, for the places that want no label at all.
160
+ *
161
+ * What a model should be given: `src/index.ts`, not `<workspace>/src/index.ts`
162
+ * and certainly not `D:\Ws\src\index.ts`. Returns null when the path is not
163
+ * inside the root, so a caller cannot accidentally send an absolute path by
164
+ * treating a failed conversion as a success.
165
+ */
166
+ export function relativeToRoot(value, root) {
167
+ if (!isInsideRoot(root, value))
168
+ return null;
169
+ const base = normalisePath(root);
170
+ const target = normalisePath(value);
171
+ return target === base ? '.' : target.slice(base.length).replace(/^\//, '');
172
+ }
173
+ /**
174
+ * Redact only the named fields of a record.
175
+ *
176
+ * The safe shape of this operation. A caller says which keys hold paths, and
177
+ * nothing else in the object is examined — so a `content`, a `transcript` or a
178
+ * `message` beside them is carried through byte for byte.
179
+ *
180
+ * @param record - the object to copy.
181
+ * @param fields - the keys whose string values are paths.
182
+ * @param roots - the roots to measure against.
183
+ * @returns a shallow copy with those fields converted.
184
+ */
185
+ export function redactFields(record, fields, roots) {
186
+ const out = { ...record };
187
+ for (const field of fields) {
188
+ const value = out[field];
189
+ if (typeof value === 'string')
190
+ out[field] = redactPath(value, roots);
191
+ }
192
+ return out;
193
+ }
194
+ /**
195
+ * Redact one diagnostic string a caller has identified as path-bearing.
196
+ *
197
+ * Bounded on purpose: it rewrites only complete path tokens, delimited by
198
+ * whitespace, quotes or the common punctuation a path is wrapped in when it
199
+ * lands in a message. It is for a log line or an error detail whose shape the
200
+ * caller knows — never for evidence, a transcript, a captured document or
201
+ * anything a person wrote.
202
+ */
203
+ export function redactDiagnosticText(text, roots) {
204
+ let out = text;
205
+ const ordered = [...roots].sort((a, b) => normalisePath(b.path).length - normalisePath(a.path).length);
206
+ for (const root of ordered) {
207
+ const base = normalisePath(root.path);
208
+ // Both spellings, because a path reaches a message through Node (forward
209
+ // slashes) and through a shell or a Windows API (backslashes) equally often.
210
+ for (const spelling of [base, base.replace(/\//g, '\\')]) {
211
+ const pattern = new RegExp(
212
+ // portability-ok: a character class matching either separator, not a path
213
+ // this module builds. Reading paths written on another platform is the job.
214
+ `${escapeForPattern(spelling)}(?![A-Za-z0-9_.-])([\\\\/][^\\s"'\`,;)\\]]*)?`, 'g'); // portability-ok
215
+ out = out.replace(pattern, (_match, rest) => rest === undefined || rest === ''
216
+ ? labelOf(root)
217
+ : `${labelOf(root)}${rest.replace(/\\/g, '/')}`);
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+ /** Escape a literal for use inside a regular expression. */
223
+ function escapeForPattern(value) {
224
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
225
+ }
226
+ /**
227
+ * POSIX directories that hold a person's machine rather than a server's URL
228
+ * space.
229
+ *
230
+ * A bare leading `/` cannot be the test: `/api/v1/chat` is a URL path and
231
+ * appears in every honest diagnostic this product writes, so matching it would
232
+ * make the assertion below fire constantly and be switched off. These are the
233
+ * roots a real leak comes from.
234
+ */
235
+ const POSIX_LOCAL_ROOTS = ['home', 'Users', 'var', 'tmp', 'root', 'mnt', 'media', 'opt', 'private'];
236
+ /**
237
+ * Every absolute local path a string still contains.
238
+ *
239
+ * The test-facing half. A surface that must never carry one asserts this is
240
+ * empty, and gets the offending text back rather than a bare false.
241
+ *
242
+ * A path preceded by `//` in a URL is skipped: `https://host/home/x` names a
243
+ * server's route, not this machine's disk, and reporting it would be a false
244
+ * alarm in exactly the diagnostics a reader needs.
245
+ *
246
+ * The same false alarm reached the Windows branch by a subtler route, and cost
247
+ * more: a drive letter is one letter followed by a colon, and `https:` ends in
248
+ * exactly that shape. Every ordinary URL was therefore reported as a local
249
+ * path — `s://api.example.com` — so `assertNoLocalPath` threw on honest
250
+ * diagnostics like "failed to reach https://…", which is precisely the kind of
251
+ * guard somebody switches off. The lookbehind is what makes a drive letter a
252
+ * drive letter: nothing alphabetic before it, and no colon before a `//`.
253
+ *
254
+ * `file:///D:/Ws/x` still matches, and should: a file URL carries a real local
255
+ * path, and the `D:` in it is preceded by `/` rather than by a scheme.
256
+ */
257
+ export function findAbsolutePaths(text) {
258
+ const found = [];
259
+ const windows = /(?<![A-Za-z:])(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/\s]+[\\/])[^\s"'`,;)\]]*/g;
260
+ for (const match of text.matchAll(windows))
261
+ found.push(match[0]);
262
+ // portability-ok: the escaped class is regex syntax, not a path separator.
263
+ const posix = new RegExp(`(^|[^A-Za-z0-9_:/])(/(?:${POSIX_LOCAL_ROOTS.join('|')})/[^\\s"'\`,;)\\]]*)`, 'g'); // portability-ok
264
+ for (const match of text.matchAll(posix)) {
265
+ const value = match[2];
266
+ if (value !== undefined)
267
+ found.push(value);
268
+ }
269
+ return found;
270
+ }
271
+ /**
272
+ * Throw when a value that must be free of local paths is not.
273
+ *
274
+ * Used at the boundaries where a passthrough would be a leak rather than a
275
+ * convenience: a provider payload, an export, a rendered surface. The message
276
+ * names the field and the offending token so the failure is fixable, and it is
277
+ * a programming error rather than a runtime condition.
278
+ */
279
+ export function assertNoLocalPath(where, value) {
280
+ const found = findAbsolutePaths(value);
281
+ if (found.length === 0)
282
+ return;
283
+ throw new Error(`${where} carries ${String(found.length)} absolute local path(s), starting with `
284
+ + `${String(found[0])}. Convert it with redactPath or relativeToRoot first.`);
285
+ }
286
+ //# sourceMappingURL=paths.js.map