taphound 0.2.0-dev.12 → 0.2.0-dev.13
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/assets/skills/taphound-case-suite/SKILL.md +8 -6
- package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
- package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
- package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
- package/assets/skills/taphound-journey-generator/SKILL.md +14 -5
- package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
- package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
- package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +62 -9
- package/dist/application/generation/proposed-step-validator.js +5 -1
- package/dist/application/journey/journey-check-service.d.ts +7 -1
- package/dist/application/journey/journey-check-service.js +16 -1
- package/dist/application/report/report-writer.js +6 -2
- package/dist/application/runtime/verify-runtime.js +9 -1
- package/dist/application/wait/idle-waiter.js +10 -2
- package/dist/cli/commands/generation/session-commands.js +4 -1
- package/dist/cli/commands/journey.js +3 -1
- package/dist/domain/report.d.ts +8 -0
- package/dist/domain/report.js +1 -0
- package/dist/domain/workspace.d.ts +9 -0
- package/dist/domain/workspace.js +12 -0
- package/package.json +3 -2
- package/scripts/feedback-pack.mjs +399 -0
|
@@ -53,11 +53,13 @@ Use `node <skill>/scripts/ledger.mjs help` for the helper contract.
|
|
|
53
53
|
|
|
54
54
|
## Durable suite directory
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
A Suite always lives in `.taphound/suites/<suite-id>/` under the project
|
|
57
|
+
root; the helper derives that directory from the Suite ID and rejects any
|
|
58
|
+
other location (and a Brief anywhere but its Case directory) with
|
|
59
|
+
`CASE_SUITE_LOCATION`:
|
|
58
60
|
|
|
59
61
|
```text
|
|
60
|
-
|
|
62
|
+
.taphound/suites/<suite-id>/
|
|
61
63
|
├── cases.json # frozen user-approved Case catalog
|
|
62
64
|
├── case-ledger.json # mutable, revisioned orchestration state
|
|
63
65
|
├── STATUS.md # generated human view; never the Source of Truth
|
|
@@ -95,8 +97,7 @@ Create an input from `templates/suite-input.example.json`, then:
|
|
|
95
97
|
|
|
96
98
|
```bash
|
|
97
99
|
node <skill>/scripts/ledger.mjs init \
|
|
98
|
-
--input /tmp/taphound-suite-input.json
|
|
99
|
-
--out <project>/doc/development/<suite-id>
|
|
100
|
+
--input /tmp/taphound-suite-input.json
|
|
100
101
|
```
|
|
101
102
|
|
|
102
103
|
Initialization canonicalizes `projectRoot`, validates dependency cycles and
|
|
@@ -215,7 +216,8 @@ Dispatch exactly one Case to `taphound-journey-brief-author` with:
|
|
|
215
216
|
- exact catalog `sourceText` as `caseGoal`;
|
|
216
217
|
- explicit context paths only;
|
|
217
218
|
- output
|
|
218
|
-
|
|
219
|
+
`.taphound/suites/<suite-id>/briefs/<case-id>/taphound-journey-brief.md`
|
|
220
|
+
(the only path `briefReady` accepts).
|
|
219
221
|
|
|
220
222
|
After authoring, compute its exact hash and transition:
|
|
221
223
|
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
dirname, isAbsolute, join, relative, resolve, sep
|
|
9
9
|
} from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
|
|
12
|
+
// Suites are committed TapHound project material; the layout mirrors
|
|
13
|
+
// SUITES_DIR in src/domain/workspace.ts.
|
|
14
|
+
const SUITES_DIR = ".taphound/suites";
|
|
11
15
|
const CATALOG = "cases.json";
|
|
12
16
|
const LEDGER = "case-ledger.json";
|
|
13
17
|
const STATUS = "STATUS.md";
|
|
@@ -82,23 +86,36 @@ function canonicalHash(value) {
|
|
|
82
86
|
return digest(JSON.stringify(canonical(value)));
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
|
|
89
|
+
// `contract` names the object and, for command inputs, the shipped template
|
|
90
|
+
// so a rejected field points at the shape the command accepts.
|
|
91
|
+
function exactKeys(value, required, optional = [], contract = undefined) {
|
|
92
|
+
const where = contract === undefined ? "" : ` in ${contract.name}`;
|
|
93
|
+
const hint = () => {
|
|
94
|
+
const fields = [...required, ...optional.map((key) => `${key}?`)];
|
|
95
|
+
return `; allowed fields: ${fields.join(", ")}${
|
|
96
|
+
contract?.template === undefined
|
|
97
|
+
? ""
|
|
98
|
+
: `; see ${contract.template}`
|
|
99
|
+
}`;
|
|
100
|
+
};
|
|
86
101
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
87
|
-
fail("CASE_SUITE_INVALID",
|
|
102
|
+
fail("CASE_SUITE_INVALID", `Expected a JSON object${where}`);
|
|
88
103
|
}
|
|
89
104
|
const allowed = new Set([...required, ...optional]);
|
|
90
105
|
for (const key of Object.keys(value)) {
|
|
91
106
|
if (!allowed.has(key)) {
|
|
92
|
-
fail("CASE_SUITE_INVALID", `Unknown field "${key}"`);
|
|
107
|
+
fail("CASE_SUITE_INVALID", `Unknown field "${key}"${where}${hint()}`);
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
for (const key of required) {
|
|
96
111
|
if (!(key in value)) {
|
|
97
|
-
fail("CASE_SUITE_INVALID", `Missing required field "${key}"`);
|
|
112
|
+
fail("CASE_SUITE_INVALID", `Missing required field "${key}"${where}${hint()}`);
|
|
98
113
|
}
|
|
99
114
|
}
|
|
100
115
|
}
|
|
101
116
|
|
|
117
|
+
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
118
|
+
|
|
102
119
|
function nonempty(value, label, max = 1000) {
|
|
103
120
|
if (typeof value !== "string") {
|
|
104
121
|
fail("CASE_SUITE_INVALID", `${label} must be a string`);
|
|
@@ -125,6 +142,14 @@ function assertSha(value, label) {
|
|
|
125
142
|
}
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
function suiteDirectory(projectRoot, suiteId) {
|
|
146
|
+
return join(projectRoot, ...SUITES_DIR.split("/"), suiteId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function suiteBriefPath(suiteId, caseId) {
|
|
150
|
+
return `${SUITES_DIR}/${suiteId}/briefs/${caseId}/taphound-journey-brief.md`;
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
function inside(root, path) {
|
|
129
154
|
const rel = relative(root, path);
|
|
130
155
|
return rel === "" || (
|
|
@@ -459,6 +484,13 @@ async function loadSuite(suitePath) {
|
|
|
459
484
|
if (projectRoot !== catalog.projectRoot || !inside(projectRoot, root)) {
|
|
460
485
|
fail("CASE_SUITE_INVALID", "Suite must stay beneath canonical projectRoot");
|
|
461
486
|
}
|
|
487
|
+
if (root !== suiteDirectory(projectRoot, catalog.suiteId)) {
|
|
488
|
+
fail(
|
|
489
|
+
"CASE_SUITE_LOCATION",
|
|
490
|
+
`Suite ${catalog.suiteId} must live in ${SUITES_DIR}/${catalog.suiteId}, `
|
|
491
|
+
+ `found ${relative(projectRoot, root)}; initialize it there`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
462
494
|
const ledger = parseLedger(await json(ledgerPath, LEDGER), catalog);
|
|
463
495
|
const catalogSha256 = digest(catalogBytes);
|
|
464
496
|
if (ledger.catalog.sha256 !== catalogSha256) {
|
|
@@ -599,7 +631,8 @@ function parseSuiteInput(value) {
|
|
|
599
631
|
exactKeys(
|
|
600
632
|
value,
|
|
601
633
|
["version", "suiteId", "title", "projectRoot", "cases"],
|
|
602
|
-
["deviceSerial", "contextPath"]
|
|
634
|
+
["deviceSerial", "contextPath"],
|
|
635
|
+
{ name: "init input", template: join(TEMPLATES, "suite-input.example.json") }
|
|
603
636
|
);
|
|
604
637
|
if (value.version !== 1 || !suiteIdPattern.test(value.suiteId ?? "")
|
|
605
638
|
|| !isAbsolute(value.projectRoot ?? "")
|
|
@@ -629,9 +662,13 @@ function parseSuiteInput(value) {
|
|
|
629
662
|
async function init(inputPath, outputPath) {
|
|
630
663
|
const input = parseSuiteInput(await json(resolve(inputPath), "Suite input"));
|
|
631
664
|
const projectRoot = await realpath(input.projectRoot);
|
|
632
|
-
const output =
|
|
633
|
-
if (
|
|
634
|
-
fail(
|
|
665
|
+
const output = suiteDirectory(projectRoot, input.suiteId);
|
|
666
|
+
if (outputPath !== undefined && resolve(outputPath) !== output) {
|
|
667
|
+
fail(
|
|
668
|
+
"CASE_SUITE_LOCATION",
|
|
669
|
+
`Suite directory must be ${SUITES_DIR}/${input.suiteId} under projectRoot `
|
|
670
|
+
+ `(${output}); omit --out to use it: ${outputPath}`
|
|
671
|
+
);
|
|
635
672
|
}
|
|
636
673
|
await access(output).then(
|
|
637
674
|
() => fail("CASE_SUITE_EXISTS", "Suite output already exists"),
|
|
@@ -708,7 +745,8 @@ function parseTransition(value) {
|
|
|
708
745
|
exactKeys(
|
|
709
746
|
value,
|
|
710
747
|
["version", "expectedRevision", "caseId", "from", "to", "reason"],
|
|
711
|
-
["brief", "generation", "failure", "nextAction", "completion"]
|
|
748
|
+
["brief", "generation", "failure", "nextAction", "completion"],
|
|
749
|
+
{ name: "transition input", template: join(TEMPLATES, "transition.example.json") }
|
|
712
750
|
);
|
|
713
751
|
if (value.version !== 1 || !caseIdPattern.test(value.caseId ?? "")
|
|
714
752
|
|| !statuses.has(value.from) || !statuses.has(value.to)) {
|
|
@@ -861,10 +899,12 @@ async function transition(suitePath, inputPath) {
|
|
|
861
899
|
const briefFile = await regularProjectFile(
|
|
862
900
|
suite.projectRoot, request.brief, `${entry.id}.brief`
|
|
863
901
|
);
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
902
|
+
const expectedBrief = suiteBriefPath(suite.catalog.suiteId, entry.id);
|
|
903
|
+
if (briefFile.relativePath !== expectedBrief) {
|
|
904
|
+
fail(
|
|
905
|
+
"CASE_SUITE_LOCATION",
|
|
906
|
+
`Brief for ${entry.id} must be ${expectedBrief}: ${briefFile.relativePath}`
|
|
907
|
+
);
|
|
868
908
|
}
|
|
869
909
|
}
|
|
870
910
|
if (request.to === "generating") {
|
|
@@ -958,10 +998,18 @@ async function transition(suitePath, inputPath) {
|
|
|
958
998
|
}
|
|
959
999
|
|
|
960
1000
|
function parseFlowRecord(value) {
|
|
961
|
-
exactKeys(
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1001
|
+
exactKeys(
|
|
1002
|
+
value,
|
|
1003
|
+
[
|
|
1004
|
+
"version", "expectedRevision", "name", "path", "sha256",
|
|
1005
|
+
"exitActivity", "journey", "resolutionManifest", "report"
|
|
1006
|
+
],
|
|
1007
|
+
[],
|
|
1008
|
+
{
|
|
1009
|
+
name: "record-flow input",
|
|
1010
|
+
template: join(TEMPLATES, "base-flow-record.example.json")
|
|
1011
|
+
}
|
|
1012
|
+
);
|
|
965
1013
|
if (value.version !== 1 || !flowNamePattern.test(value.name ?? "")) {
|
|
966
1014
|
fail("CASE_SUITE_INVALID", "Invalid Base Flow record");
|
|
967
1015
|
}
|
|
@@ -1154,12 +1202,26 @@ function help() {
|
|
|
1154
1202
|
"TapHound Case Suite Ledger",
|
|
1155
1203
|
"",
|
|
1156
1204
|
"Commands:",
|
|
1157
|
-
" init --input <json> --out <suite-
|
|
1205
|
+
" init --input <json> [--out <project>/.taphound/suites/<suite-id>]",
|
|
1158
1206
|
" validate --suite <suite-directory>",
|
|
1159
1207
|
" status --suite <suite-directory> [--case <case-id>]",
|
|
1160
1208
|
" transition --suite <suite-directory> --input <json>",
|
|
1161
1209
|
" record-flow --suite <suite-directory> --input <json>",
|
|
1162
|
-
" recover-lock --suite <suite-directory>"
|
|
1210
|
+
" recover-lock --suite <suite-directory>",
|
|
1211
|
+
"",
|
|
1212
|
+
`Input templates live in ${TEMPLATES}: suite-input.example.json (init),`,
|
|
1213
|
+
"transition.example.json (transition), base-flow-record.example.json",
|
|
1214
|
+
"(record-flow). Unknown or missing fields are rejected with the allowed list.",
|
|
1215
|
+
"",
|
|
1216
|
+
`Layout: a Suite lives in <projectRoot>/${SUITES_DIR}/<suite-id>/ and each`,
|
|
1217
|
+
"Case Brief in its briefs/<case-id>/taphound-journey-brief.md. Other",
|
|
1218
|
+
"locations fail with CASE_SUITE_LOCATION.",
|
|
1219
|
+
"",
|
|
1220
|
+
"Ledger revision: every successful transition or record-flow increments",
|
|
1221
|
+
"ledger.revision by exactly 1. Pass the current value as expectedRevision",
|
|
1222
|
+
"(read it from `status`); a stale value fails without writing. It is",
|
|
1223
|
+
"unrelated to TapHound generation session revisions (observe +1, step +3",
|
|
1224
|
+
"with its post-action observation), which the ledger never stores."
|
|
1163
1225
|
].join("\n");
|
|
1164
1226
|
}
|
|
1165
1227
|
|
|
@@ -1172,6 +1234,9 @@ async function main() {
|
|
|
1172
1234
|
let output;
|
|
1173
1235
|
if (command === "init") {
|
|
1174
1236
|
const input = options(argv, ["--input", "--out"]);
|
|
1237
|
+
if (input.input === undefined) {
|
|
1238
|
+
fail("CASE_SUITE_USAGE", "init requires --input");
|
|
1239
|
+
}
|
|
1175
1240
|
output = await init(input.input, input.out);
|
|
1176
1241
|
} else if (command === "validate") {
|
|
1177
1242
|
const input = options(argv, ["--suite"]);
|
|
@@ -104,7 +104,7 @@ Orchestrator (lean context)
|
|
|
104
104
|
| contextOnly | no | `false` | Run only the Context lifecycle (ensure/refresh/regenerate); skip Brief authoring |
|
|
105
105
|
| observeSnapshot | no | — | Pre-captured `taphound observe --json` result |
|
|
106
106
|
| device | no | doctor auto-selects | Device serial |
|
|
107
|
-
| output | no | `.taphound/
|
|
107
|
+
| output | no | `.taphound/briefs/<caseId>/taphound-journey-brief.md` | Brief output path (relative to project); must be under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/` |
|
|
108
108
|
|
|
109
109
|
**Hard rule**: NEVER search for or assume files named `plan.md`,
|
|
110
110
|
`requirement.md`, or any convention. Read ONLY files the caller explicitly
|
|
@@ -114,13 +114,18 @@ passes via `contextPaths`. If no `contextPaths` are supplied, work from
|
|
|
114
114
|
## Output
|
|
115
115
|
|
|
116
116
|
For a Brief run, the Skill writes a `taphound-journey-brief.md` at the
|
|
117
|
-
`output` path and returns a structured JSON summary
|
|
117
|
+
`output` path and returns a structured JSON summary. Briefs are committed
|
|
118
|
+
TapHound material: write them only under `.taphound/briefs/<caseId>/`
|
|
119
|
+
(standalone) or `.taphound/suites/<suite-id>/briefs/<caseId>/` (Case Suite),
|
|
120
|
+
never elsewhere in the project (for example `doc/` or the project root).
|
|
121
|
+
`generation start --brief` rejects any other location with `BRIEF_INVALID`.
|
|
122
|
+
Without a `caseId`, use a short kebab-case name of the Goal as the directory.
|
|
118
123
|
|
|
119
124
|
```json
|
|
120
125
|
{
|
|
121
126
|
"status": "authored",
|
|
122
127
|
"caseId": "CASE-002",
|
|
123
|
-
"path": ".taphound/
|
|
128
|
+
"path": ".taphound/briefs/CASE-002/taphound-journey-brief.md",
|
|
124
129
|
"sha256": "<exact-byte-hash>",
|
|
125
130
|
"edgesVerified": 2,
|
|
126
131
|
"edgesNeedsObservation": 1
|
|
@@ -43,7 +43,9 @@ The orchestrator dispatches your task with:
|
|
|
43
43
|
- **observeSnapshot** (optional): Pre-captured `taphound observe --json`
|
|
44
44
|
result. Use it directly; do NOT call `taphound observe` when provided.
|
|
45
45
|
- **output** (optional): Brief output path, defaults to
|
|
46
|
-
`.taphound/
|
|
46
|
+
`.taphound/briefs/<caseId>/taphound-journey-brief.md` (relative to
|
|
47
|
+
project). It must stay under `.taphound/briefs/` or
|
|
48
|
+
`.taphound/suites/<suite-id>/briefs/`; refuse any other location.
|
|
47
49
|
|
|
48
50
|
## Output
|
|
49
51
|
|
|
@@ -31,7 +31,7 @@ generate`/`refresh`/`rehash`/`validate`/`status`/`list`、`taphound observe`。
|
|
|
31
31
|
| contextPaths | 否 | 显式文档路径数组,只读这些 |
|
|
32
32
|
| contextOnly | 否 | 为 `true` 时只运行 Context 生命周期(Phase 0),返回 Context 摘要 JSON,不写 Brief |
|
|
33
33
|
| observeSnapshot | 否 | 预采集的 `taphound observe --json` 结果;提供则直接用,不再调 observe |
|
|
34
|
-
| output | 否 | Brief 输出路径,默认 `.taphound/
|
|
34
|
+
| output | 否 | Brief 输出路径,默认 `.taphound/briefs/<caseId>/taphound-journey-brief.md`;只能位于 `.taphound/briefs/` 或 `.taphound/suites/<suite-id>/briefs/` 下,拒绝其他位置 |
|
|
35
35
|
|
|
36
36
|
## 输出
|
|
37
37
|
|
|
@@ -86,7 +86,8 @@ risk confirmation, recovery, or final Replay rules.
|
|
|
86
86
|
|
|
87
87
|
`journeyBrief` is the Skill-level handoff for one Journey Case. When present,
|
|
88
88
|
it carries `{path, sha256}` pointing to a project-relative
|
|
89
|
-
`taphound-journey-brief.md
|
|
89
|
+
`taphound-journey-brief.md` under `.taphound/briefs/` or
|
|
90
|
+
`.taphound/suites/<suite-id>/briefs/`. Bind the same path into Core with
|
|
90
91
|
`generation start --brief <path>`: Core reads the file itself, computes the
|
|
91
92
|
SHA-256 (never trust an agent-supplied hash), and persists `sourceBrief` in
|
|
92
93
|
the session and the exported meta sidecar, so `journey check` reports
|
|
@@ -264,12 +265,18 @@ them with `MANUAL_STEP_REQUIRED`.
|
|
|
264
265
|
node <skill>/scripts/envelope.mjs bind \
|
|
265
266
|
--input <draft-envelope-path> \
|
|
266
267
|
--from <previous-observe-or-step-output-path> \
|
|
267
|
-
--out <envelope-path>
|
|
268
|
+
--out <envelope-path> \
|
|
269
|
+
--project <project>
|
|
268
270
|
```
|
|
269
271
|
The draft envelope needs only `version` and `proposal` (binding may be
|
|
270
272
|
omitted or stale); `bind` fills `proposal.binding` from the preceding
|
|
271
273
|
observe output, step output, or raw binding, adds `snapshotRef` when
|
|
272
|
-
absent, and validates the result offline.
|
|
274
|
+
absent, and validates the result offline. It also compares
|
|
275
|
+
`proposal.activity.before` with the bound snapshot's Activity (read
|
|
276
|
+
from `snapshotRef` under `--project`) and fails with
|
|
277
|
+
`ENVELOPE_ACTIVITY_MISMATCH`, naming the snapshot Activity, before any
|
|
278
|
+
device work. `activityCheck: "unverified"` in its output means no
|
|
279
|
+
snapshot was readable; Core still enforces the check on `step`. The helper contract:
|
|
273
280
|
`node <skill>/scripts/envelope.mjs help`. The resulting shape:
|
|
274
281
|
```json
|
|
275
282
|
{
|
|
@@ -375,10 +382,12 @@ the index with `taphound knowledge rehash --project <project> --json` and
|
|
|
375
382
|
taphound journey check \
|
|
376
383
|
--project <project> \
|
|
377
384
|
--context .taphound/context/project-context.json \
|
|
385
|
+
--journey .taphound/journeys/<name>.json \
|
|
378
386
|
--json
|
|
379
387
|
```
|
|
380
|
-
The newly published Journey must classify as `fresh`.
|
|
381
|
-
|
|
388
|
+
The newly published Journey must classify as `fresh`. `--journey`
|
|
389
|
+
limits the audit to that Journey; without it `journey check` audits every
|
|
390
|
+
committed Journey under `.taphound/journeys` by comparing
|
|
382
391
|
its sidecar bindings (project, config, and `contextSelection` module
|
|
383
392
|
hashes, plus the Brief content hash when `sourceBrief` is bound) against
|
|
384
393
|
the live project. `--strict` exits `1` when any Journey
|
|
@@ -11,7 +11,9 @@ Use this prompt only when the caller supplied `journeyBrief`.
|
|
|
11
11
|
## Validation
|
|
12
12
|
|
|
13
13
|
1. Resolve the path beneath the project root without following a symlink
|
|
14
|
-
outside it. Its basename must be `taphound-journey-brief.md
|
|
14
|
+
outside it. Its basename must be `taphound-journey-brief.md`, and it must
|
|
15
|
+
live under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/`
|
|
16
|
+
(Core rejects other locations with `BRIEF_INVALID`).
|
|
15
17
|
2. Compute SHA-256 over the exact file bytes and compare it with the binding.
|
|
16
18
|
3. Require YAML frontmatter values:
|
|
17
19
|
- `schemaVersion: 2`
|
|
@@ -102,6 +102,15 @@ the observe result).
|
|
|
102
102
|
varying middle can identify the event, use `match: "regex"` and anchor
|
|
103
103
|
the stable words instead of the varying text.
|
|
104
104
|
- `activity`: a specific Activity should be foregrounded.
|
|
105
|
+
- **Screens that load asynchronously**: idle detection only proves the
|
|
106
|
+
layout tree stopped changing. A full-screen spinner or skeleton is a
|
|
107
|
+
static tree, so the post-action snapshot can be the loading state. When
|
|
108
|
+
an action opens a screen that loads, prefer an `element` expect on a
|
|
109
|
+
control of the loaded screen: Core polls until it appears and returns
|
|
110
|
+
that settled layout as the next snapshot. When the step needs a `logcat`
|
|
111
|
+
expect instead, follow it with a `wait` step whose `element` expect names
|
|
112
|
+
the loaded screen's control, rather than proposing the next action
|
|
113
|
+
against a loading snapshot.
|
|
105
114
|
- Do not add expectations you cannot verify from source code or Context.
|
|
106
115
|
- Do not invent log patterns that don't exist in the source.
|
|
107
116
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// schema changes, update this script and its test together.
|
|
10
10
|
import process from "node:process";
|
|
11
11
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
12
|
-
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
13
|
|
|
14
14
|
const sha256Pattern = /^[a-f\d]{64}$/;
|
|
15
15
|
const generationIdPattern = /^[A-Za-z\d](?:[A-Za-z\d._-]*[A-Za-z\d])?$/;
|
|
@@ -456,7 +456,36 @@ async function readJsonFile(path, label) {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
|
|
459
|
+
// The Activity the bound snapshot was captured on, taken from the bind source
|
|
460
|
+
// (full observe/step output), the inline envelope snapshot, or the
|
|
461
|
+
// Store-owned snapshot file under the project root. Undefined when none of
|
|
462
|
+
// them is readable; Core still enforces the check on `generation step`.
|
|
463
|
+
async function boundSnapshotActivity(source, envelope, snapshotRef, projectRoot) {
|
|
464
|
+
const inline = [
|
|
465
|
+
["bind source snapshot", source.snapshot],
|
|
466
|
+
["bind source nextSnapshot", source.nextSnapshot],
|
|
467
|
+
["envelope.snapshot", envelope.snapshot]
|
|
468
|
+
];
|
|
469
|
+
for (const [from, snapshot] of inline) {
|
|
470
|
+
if (isPlainObject(snapshot) && typeof snapshot.activity === "string") {
|
|
471
|
+
return { activity: snapshot.activity, from };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const ref = envelope.snapshotRef ?? snapshotRef;
|
|
475
|
+
if (typeof ref !== "string" || !snapshotRefPattern.test(ref)) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const snapshot = JSON.parse(await readFile(join(projectRoot, ref), "utf8"));
|
|
480
|
+
return isPlainObject(snapshot) && typeof snapshot.activity === "string"
|
|
481
|
+
? { activity: snapshot.activity, from: ref }
|
|
482
|
+
: undefined;
|
|
483
|
+
} catch {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function bind(inputPath, fromPath, outPath, projectRoot) {
|
|
460
489
|
const envelope = await readJsonFile(inputPath, "envelope input");
|
|
461
490
|
exactKeys(
|
|
462
491
|
"envelope input",
|
|
@@ -494,6 +523,23 @@ async function bind(inputPath, fromPath, outPath) {
|
|
|
494
523
|
: { snapshot: envelope.snapshot })
|
|
495
524
|
};
|
|
496
525
|
validateEnvelope(bound);
|
|
526
|
+
const snapshotActivity = await boundSnapshotActivity(
|
|
527
|
+
source,
|
|
528
|
+
bound,
|
|
529
|
+
snapshotRef,
|
|
530
|
+
projectRoot
|
|
531
|
+
);
|
|
532
|
+
const before = proposal.activity?.before;
|
|
533
|
+
if (snapshotActivity !== undefined && before !== snapshotActivity.activity) {
|
|
534
|
+
fail(
|
|
535
|
+
"ENVELOPE_ACTIVITY_MISMATCH",
|
|
536
|
+
`proposal.activity.before ${String(before)} does not match the bound snapshot Activity ${
|
|
537
|
+
snapshotActivity.activity
|
|
538
|
+
} (from ${snapshotActivity.from}); set activity.before to ${
|
|
539
|
+
snapshotActivity.activity
|
|
540
|
+
}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
497
543
|
if (outPath !== undefined) {
|
|
498
544
|
const absolute = resolve(outPath);
|
|
499
545
|
await mkdir(dirname(absolute), { recursive: true });
|
|
@@ -505,7 +551,8 @@ async function bind(inputPath, fromPath, outPath) {
|
|
|
505
551
|
exitCode: 0,
|
|
506
552
|
path: outPath,
|
|
507
553
|
binding,
|
|
508
|
-
...(snapshotRef === undefined ? {} : { snapshotRef })
|
|
554
|
+
...(snapshotRef === undefined ? {} : { snapshotRef }),
|
|
555
|
+
activityCheck: snapshotActivity === undefined ? "unverified" : "matched"
|
|
509
556
|
})}\n`);
|
|
510
557
|
return;
|
|
511
558
|
}
|
|
@@ -532,16 +579,22 @@ function help() {
|
|
|
532
579
|
"Commands:",
|
|
533
580
|
" validate --input <envelope.json>",
|
|
534
581
|
" Validate one generation step envelope offline (no device, no session).",
|
|
535
|
-
" bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>]",
|
|
582
|
+
" bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>] [--project <root>]",
|
|
536
583
|
" Fill proposal.binding (and snapshotRef when absent) from the preceding",
|
|
537
584
|
" observe output, step output, or raw binding, then validate.",
|
|
585
|
+
" It also checks proposal.activity.before against the bound snapshot's",
|
|
586
|
+
" Activity (from the full output, the inline snapshot, or the snapshot",
|
|
587
|
+
" file under --project, default the current directory) and fails with",
|
|
588
|
+
" ENVELOPE_ACTIVITY_MISMATCH naming the snapshot Activity. With --out,",
|
|
589
|
+
" activityCheck reports \"matched\" or \"unverified\" (no readable snapshot).",
|
|
538
590
|
"",
|
|
539
591
|
"bind writes the bound envelope to --out; without --out the bound envelope",
|
|
540
592
|
"itself is the single stdout JSON value.",
|
|
541
593
|
"",
|
|
542
|
-
"Revision rule:
|
|
543
|
-
"
|
|
544
|
-
"
|
|
594
|
+
"Revision rule: observe advances the session revision by 1. A succeeded",
|
|
595
|
+
"step advances it by 2 for the step plus 1 for the post-action observation",
|
|
596
|
+
"behind nextBinding, so consecutive steps are 3 apart. Never compute",
|
|
597
|
+
"revisions: bind from the latest observe or step output."
|
|
545
598
|
].join("\n");
|
|
546
599
|
}
|
|
547
600
|
|
|
@@ -558,11 +611,11 @@ async function main() {
|
|
|
558
611
|
return;
|
|
559
612
|
}
|
|
560
613
|
if (command === "bind") {
|
|
561
|
-
const input = options(argv, ["--input", "--from", "--out"]);
|
|
614
|
+
const input = options(argv, ["--input", "--from", "--out", "--project"]);
|
|
562
615
|
if (input.input === undefined || input.from === undefined) {
|
|
563
616
|
fail("ENVELOPE_USAGE", "bind requires --input and --from");
|
|
564
617
|
}
|
|
565
|
-
await bind(input.input, input.from, input.out);
|
|
618
|
+
await bind(input.input, input.from, input.out, resolve(input.project ?? "."));
|
|
566
619
|
return;
|
|
567
620
|
}
|
|
568
621
|
fail("ENVELOPE_USAGE", `Unknown command ${command}`);
|
|
@@ -51,7 +51,11 @@ function validateBinding(session, snapshot, proposal) {
|
|
|
51
51
|
throw new GenerationOperationError("PACKAGE_ESCAPE", "Foreground package escaped the generation target");
|
|
52
52
|
}
|
|
53
53
|
if (snapshot.activity !== proposal.activity.before) {
|
|
54
|
-
throw new GenerationOperationError("SNAPSHOT_STALE",
|
|
54
|
+
throw new GenerationOperationError("SNAPSHOT_STALE", `Proposal before Activity ${proposal.activity.before} does not match the current snapshot Activity ${snapshot.activity}`, {
|
|
55
|
+
field: "activity.before",
|
|
56
|
+
expected: snapshot.activity,
|
|
57
|
+
actual: proposal.activity.before
|
|
58
|
+
});
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
function validateAction(snapshot, proposal) {
|
|
@@ -30,7 +30,7 @@ export interface JourneyCheckResult {
|
|
|
30
30
|
entries: readonly JourneyCheckEntry[];
|
|
31
31
|
summary: JourneyCheckSummary;
|
|
32
32
|
}
|
|
33
|
-
export type JourneyCheckErrorCode = "CONFIG_INVALID";
|
|
33
|
+
export type JourneyCheckErrorCode = "CONFIG_INVALID" | "JOURNEY_NOT_FOUND";
|
|
34
34
|
export declare class JourneyCheckError extends Error {
|
|
35
35
|
readonly code: JourneyCheckErrorCode;
|
|
36
36
|
readonly name = "JourneyCheckError";
|
|
@@ -41,6 +41,12 @@ export interface JourneyCheckInput {
|
|
|
41
41
|
config: TapHoundConfig;
|
|
42
42
|
project: ProjectDescription;
|
|
43
43
|
bundle: ProjectContext;
|
|
44
|
+
/**
|
|
45
|
+
* Restrict the check to these Journeys. Each selector is a project-relative
|
|
46
|
+
* Journey path (`.taphound/journeys/<name>.json`) or a Journey name
|
|
47
|
+
* (`<name>`). A selector that matches no committed Journey fails.
|
|
48
|
+
*/
|
|
49
|
+
journeys?: readonly string[] | undefined;
|
|
44
50
|
}
|
|
45
51
|
export interface JourneyCheckDependencies {
|
|
46
52
|
store: Pick<JourneyCompositionStore, "read" | "listJourneyPaths" | "readJourneyMeta">;
|
|
@@ -21,6 +21,21 @@ function journeyName(journeyPath) {
|
|
|
21
21
|
: journeyPath;
|
|
22
22
|
return withoutPrefix.slice(0, -".json".length);
|
|
23
23
|
}
|
|
24
|
+
function selectJourneys(paths, selectors) {
|
|
25
|
+
if (selectors === undefined || selectors.length === 0) {
|
|
26
|
+
return paths;
|
|
27
|
+
}
|
|
28
|
+
const normalize = (value) => value
|
|
29
|
+
.replaceAll("\\", "/")
|
|
30
|
+
.replace(/^\.\//, "");
|
|
31
|
+
const wanted = new Set(selectors.map(normalize));
|
|
32
|
+
const selected = paths.filter((path) => (wanted.has(path) || wanted.has(journeyName(path))));
|
|
33
|
+
const missing = [...wanted].filter((selector) => !selected.some((path) => (path === selector || journeyName(path) === selector)));
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
throw new JourneyCheckError("JOURNEY_NOT_FOUND", `No committed Journey matches ${missing.join(", ")} under ${JOURNEYS_DIR}`);
|
|
36
|
+
}
|
|
37
|
+
return selected;
|
|
38
|
+
}
|
|
24
39
|
function summarize(entries) {
|
|
25
40
|
const summary = {
|
|
26
41
|
total: entries.length,
|
|
@@ -54,7 +69,7 @@ export class JourneyCheckService {
|
|
|
54
69
|
this.dependencies = dependencies;
|
|
55
70
|
}
|
|
56
71
|
check = async (input) => {
|
|
57
|
-
const paths = await this.dependencies.store.listJourneyPaths(input.projectRoot);
|
|
72
|
+
const paths = selectJourneys(await this.dependencies.store.listJourneyPaths(input.projectRoot), input.journeys);
|
|
58
73
|
const projectHash = hashGenerationBinding(input.project);
|
|
59
74
|
const configHash = hashGenerationBinding(input.config);
|
|
60
75
|
const modulesById = new Map(input.bundle.modules.map((module) => [module.id, module]));
|
|
@@ -9,8 +9,12 @@ export function logcatEvidenceWarning(report) {
|
|
|
9
9
|
if (entries.length === 0)
|
|
10
10
|
return undefined;
|
|
11
11
|
const lines = entries.reduce((total, entry) => total + entry.droppedLines, 0);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (entries.every((entry) => entry.expectationImpact === "none")) {
|
|
13
|
+
return `Warning (non-fatal): Logcat capture is partial (${String(lines)} line(s) dropped); `
|
|
14
|
+
+ "no Logcat expectation failed, and those expectations fail closed on drops in their window";
|
|
15
|
+
}
|
|
16
|
+
return `Warning: Logcat evidence is incomplete (${String(lines)} line(s) dropped) `
|
|
17
|
+
+ "and a Logcat expectation failed; the drops may be the cause";
|
|
14
18
|
}
|
|
15
19
|
function renderSummary(report) {
|
|
16
20
|
const lines = [
|
|
@@ -658,6 +658,13 @@ export class VerifyRuntime {
|
|
|
658
658
|
].includes(failure.code)
|
|
659
659
|
? "error"
|
|
660
660
|
: "failed";
|
|
661
|
+
// Logcat expectations fail closed on a relevant drop, so drops can only
|
|
662
|
+
// have changed the outcome of a Logcat expectation that failed.
|
|
663
|
+
const expectationImpact = steps.some((step) => (step.expectation?.status === "failed"
|
|
664
|
+
&& (step.expectation.type === "logcat"
|
|
665
|
+
|| step.expectation.type === "logcatEvent")))
|
|
666
|
+
? "possible"
|
|
667
|
+
: "none";
|
|
661
668
|
const report = {
|
|
662
669
|
schemaVersion: 4,
|
|
663
670
|
...(runtimes.some((runtime) => runtime.logcatStarted
|
|
@@ -675,7 +682,8 @@ export class VerifyRuntime {
|
|
|
675
682
|
...(metadata.lastDroppedAtMs === undefined ? {} : {
|
|
676
683
|
lastDroppedAtMs: metadata.lastDroppedAtMs
|
|
677
684
|
}),
|
|
678
|
-
status: "incomplete"
|
|
685
|
+
status: "incomplete",
|
|
686
|
+
expectationImpact
|
|
679
687
|
}];
|
|
680
688
|
})
|
|
681
689
|
}
|
|
@@ -115,7 +115,8 @@ export class IdleWaiter {
|
|
|
115
115
|
durationMs: this.clock.now() - startedAt
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
-
const
|
|
118
|
+
const pollStartedAt = this.clock.now();
|
|
119
|
+
const elapsedBeforePoll = pollStartedAt - startedAt;
|
|
119
120
|
polls += 1;
|
|
120
121
|
let observation;
|
|
121
122
|
try {
|
|
@@ -246,8 +247,15 @@ export class IdleWaiter {
|
|
|
246
247
|
...telemetry(strategy, backend, fallbackUsed, frameActivityDetected, samplingDurationMs)
|
|
247
248
|
};
|
|
248
249
|
}
|
|
250
|
+
// The interval runs from the start of one poll to the start of the
|
|
251
|
+
// next: a slow sample (a full hierarchy dump) eats into the wait
|
|
252
|
+
// instead of being added on top of it.
|
|
253
|
+
const sleepMs = Math.min(config.pollIntervalMs - (this.clock.now() - pollStartedAt), remainingMs);
|
|
254
|
+
if (sleepMs <= 0) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
249
257
|
try {
|
|
250
|
-
await this.clock.sleep(
|
|
258
|
+
await this.clock.sleep(sleepMs, signal);
|
|
251
259
|
}
|
|
252
260
|
catch (error) {
|
|
253
261
|
if (isAborted(signal)) {
|
|
@@ -7,7 +7,7 @@ import { GenerationOperationError, flowReplayFailureDetails } from "../../../app
|
|
|
7
7
|
import { GenerationSessionIdSchema, verificationPhaseLabel } from "../../../domain/generation.js";
|
|
8
8
|
import { DEFAULT_DEVICE_ROLE } from "../../../domain/journey.js";
|
|
9
9
|
import { ProjectRelativePathSchema } from "../../../domain/project-context.js";
|
|
10
|
-
import { CONFIG_PATH, CONTEXT_INDEX_PATH } from "../../../domain/workspace.js";
|
|
10
|
+
import { CONFIG_PATH, CONTEXT_INDEX_PATH, isJourneyBriefPath, JOURNEY_BRIEF_ROOTS } from "../../../domain/workspace.js";
|
|
11
11
|
import { GenerationSessionStoreError } from "../../../ports/generation-session-store.js";
|
|
12
12
|
import { errorMessage, writeJson, writeLine } from "../../output.js";
|
|
13
13
|
import { canonicalProjectRoot } from "../../project-root.js";
|
|
@@ -52,6 +52,9 @@ export function createStartCommand(dependencies) {
|
|
|
52
52
|
catch (error) {
|
|
53
53
|
throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief path must stay within the project: ${briefRequest} (${error instanceof Error ? error.message : String(error)})`);
|
|
54
54
|
}
|
|
55
|
+
if (!isJourneyBriefPath(briefPath)) {
|
|
56
|
+
throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief must live under ${JOURNEY_BRIEF_ROOTS.join("/ or ")}/: ${briefPath}`);
|
|
57
|
+
}
|
|
55
58
|
let bytes;
|
|
56
59
|
try {
|
|
57
60
|
bytes = await dependencies.readFile(resolve(projectRoot, briefPath));
|
|
@@ -213,6 +213,7 @@ function createCheckCommand(dependencies) {
|
|
|
213
213
|
.option("--project <path>", "Android project root", dependencies.cwd())
|
|
214
214
|
.option("--config <path>", "TapHound config path", CONFIG_PATH)
|
|
215
215
|
.option("--context <path>", "Project Context index path")
|
|
216
|
+
.option("--journey <path-or-name...>", "Check only these Journeys (project-relative path or Journey name)")
|
|
216
217
|
.option("--json", "Emit one machine-readable JSON value")
|
|
217
218
|
.option("--strict", "Exit non-zero when any Journey is not fresh")
|
|
218
219
|
.action(async (options) => {
|
|
@@ -245,7 +246,8 @@ function createCheckCommand(dependencies) {
|
|
|
245
246
|
projectRoot,
|
|
246
247
|
config,
|
|
247
248
|
project,
|
|
248
|
-
bundle: index.bundle
|
|
249
|
+
bundle: index.bundle,
|
|
250
|
+
journeys: options.journey
|
|
249
251
|
});
|
|
250
252
|
const notFresh = result.summary.total - result.summary.fresh;
|
|
251
253
|
const exitCode = options.strict === true && notFresh > 0 ? 1 : 0;
|
package/dist/domain/report.d.ts
CHANGED
|
@@ -851,6 +851,10 @@ export declare const TapHoundReportV4Schema: z.ZodObject<{
|
|
|
851
851
|
logcatEvidence: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
852
852
|
role: z.ZodString;
|
|
853
853
|
status: z.ZodLiteral<"incomplete">;
|
|
854
|
+
expectationImpact: z.ZodEnum<{
|
|
855
|
+
none: "none";
|
|
856
|
+
possible: "possible";
|
|
857
|
+
}>;
|
|
854
858
|
droppedLines: z.ZodNumber;
|
|
855
859
|
droppedBytes: z.ZodNumber;
|
|
856
860
|
lastDroppedAtMs: z.ZodOptional<z.ZodNumber>;
|
|
@@ -1478,6 +1482,10 @@ export declare const TapHoundReportSchema: z.ZodObject<{
|
|
|
1478
1482
|
logcatEvidence: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
1479
1483
|
role: z.ZodString;
|
|
1480
1484
|
status: z.ZodLiteral<"incomplete">;
|
|
1485
|
+
expectationImpact: z.ZodEnum<{
|
|
1486
|
+
none: "none";
|
|
1487
|
+
possible: "possible";
|
|
1488
|
+
}>;
|
|
1481
1489
|
droppedLines: z.ZodNumber;
|
|
1482
1490
|
droppedBytes: z.ZodNumber;
|
|
1483
1491
|
lastDroppedAtMs: z.ZodOptional<z.ZodNumber>;
|
package/dist/domain/report.js
CHANGED
|
@@ -264,6 +264,7 @@ const ReportFields = {
|
|
|
264
264
|
logcatEvidence: z.array(z.strictObject({
|
|
265
265
|
role: DeviceRoleSchema,
|
|
266
266
|
status: z.literal("incomplete"),
|
|
267
|
+
expectationImpact: z.enum(["none", "possible"]),
|
|
267
268
|
droppedLines: z.number().int().positive(),
|
|
268
269
|
droppedBytes: z.number().int().positive(),
|
|
269
270
|
lastDroppedAtMs: z.number().nonnegative().optional()
|
|
@@ -12,6 +12,15 @@ export declare const JOURNEY_SOURCES_DIR = ".taphound/sources";
|
|
|
12
12
|
export declare const JOURNEYS_DIR = ".taphound/journeys";
|
|
13
13
|
export declare const CONTRACTS_DIR = ".taphound/contracts";
|
|
14
14
|
export declare const BASELINES_DIR = ".taphound/baselines";
|
|
15
|
+
export declare const BRIEFS_DIR = ".taphound/briefs";
|
|
16
|
+
export declare const SUITES_DIR = ".taphound/suites";
|
|
17
|
+
/**
|
|
18
|
+
* Journey Briefs are committed project material: standalone Briefs live under
|
|
19
|
+
* `.taphound/briefs/`, Case Suite Briefs under `.taphound/suites/<suite-id>/`.
|
|
20
|
+
* Nothing else in the project may be bound as a Brief.
|
|
21
|
+
*/
|
|
22
|
+
export declare const JOURNEY_BRIEF_ROOTS: readonly [".taphound/briefs", ".taphound/suites"];
|
|
23
|
+
export declare function isJourneyBriefPath(path: string): boolean;
|
|
15
24
|
export declare const BUILD_DIR = ".taphound/build";
|
|
16
25
|
export declare const GENERATIONS_DIR = ".taphound/build/generations";
|
|
17
26
|
export declare const JOBS_DIR = ".taphound/build/jobs";
|
package/dist/domain/workspace.js
CHANGED
|
@@ -14,6 +14,18 @@ export const JOURNEY_SOURCES_DIR = `${TAPHOUND_DIR}/sources`;
|
|
|
14
14
|
export const JOURNEYS_DIR = `${TAPHOUND_DIR}/journeys`;
|
|
15
15
|
export const CONTRACTS_DIR = `${TAPHOUND_DIR}/contracts`;
|
|
16
16
|
export const BASELINES_DIR = `${TAPHOUND_DIR}/baselines`;
|
|
17
|
+
export const BRIEFS_DIR = `${TAPHOUND_DIR}/briefs`;
|
|
18
|
+
export const SUITES_DIR = `${TAPHOUND_DIR}/suites`;
|
|
19
|
+
/**
|
|
20
|
+
* Journey Briefs are committed project material: standalone Briefs live under
|
|
21
|
+
* `.taphound/briefs/`, Case Suite Briefs under `.taphound/suites/<suite-id>/`.
|
|
22
|
+
* Nothing else in the project may be bound as a Brief.
|
|
23
|
+
*/
|
|
24
|
+
export const JOURNEY_BRIEF_ROOTS = [BRIEFS_DIR, SUITES_DIR];
|
|
25
|
+
export function isJourneyBriefPath(path) {
|
|
26
|
+
const normalized = posix.normalize(path.replaceAll("\\", "/"));
|
|
27
|
+
return JOURNEY_BRIEF_ROOTS.some((root) => normalized.startsWith(`${root}/`));
|
|
28
|
+
}
|
|
17
29
|
export const BUILD_DIR = `${TAPHOUND_DIR}/build`;
|
|
18
30
|
export const GENERATIONS_DIR = `${BUILD_DIR}/generations`;
|
|
19
31
|
export const JOBS_DIR = `${BUILD_DIR}/jobs`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taphound",
|
|
3
|
-
"version": "0.2.0-dev.
|
|
3
|
+
"version": "0.2.0-dev.13",
|
|
4
4
|
"description": "Deterministic app journey recording and verification",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
32
|
"assets/brand/taphound-mark.svg",
|
|
33
|
-
"assets/skills"
|
|
33
|
+
"assets/skills",
|
|
34
|
+
"scripts/feedback-pack.mjs"
|
|
34
35
|
],
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=22"
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Redact and pack TapHound generation evidence for a feedback report.
|
|
3
|
+
// Standalone: needs only Node.js and `tar`, not a TapHound checkout.
|
|
4
|
+
//
|
|
5
|
+
// Usage (run from the Android project root, or pass --project):
|
|
6
|
+
// node feedback-pack.mjs [--project <root>] [--package <pkg>]
|
|
7
|
+
// [--generation <id>]... [--run <runId>]... [--out <file.tgz>]
|
|
8
|
+
//
|
|
9
|
+
// Unlike `taphound diagnose export` (allowlisted counters, no evidence), this
|
|
10
|
+
// keeps step timing, idle telemetry, and full layout snapshots, which is what
|
|
11
|
+
// timing and screen-state problems need. See docs/diagnostics.md.
|
|
12
|
+
//
|
|
13
|
+
// Collects every JSON file under .taphound/build/generations/<id>/ (including
|
|
14
|
+
// active .<id>.work bundles) and, optionally, .taphound/build/runs/<runId>/
|
|
15
|
+
// report.json. Screenshots, Logcat text, and other non-JSON files are never
|
|
16
|
+
// copied. Package, Activity/class names, resource ids, UI text, Logcat
|
|
17
|
+
// tags/patterns, device serials, and absolute paths are replaced by stable
|
|
18
|
+
// pseudonyms. The pseudonym mapping is written next to the archive, never
|
|
19
|
+
// inside it, so you can translate references back.
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
import {
|
|
24
|
+
existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync,
|
|
25
|
+
statSync, writeFileSync
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { homedir, tmpdir } from "node:os";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
|
|
30
|
+
const HELP = `Usage: node feedback-pack.mjs [options]
|
|
31
|
+
|
|
32
|
+
--project <root> Android project root (default: current directory)
|
|
33
|
+
--package <pkg> App package (default: run.packageName in .taphound/config.json)
|
|
34
|
+
--org-prefix <pfx> Extra class-name prefix to redact, e.g. com.acme (repeatable)
|
|
35
|
+
--generation <id> Only this generation id (repeatable; default: all)
|
|
36
|
+
--run <runId> Also include .taphound/build/runs/<runId>/report.json (repeatable)
|
|
37
|
+
--out <file> Archive path (default: .taphound/build/diagnostics/
|
|
38
|
+
taphound-feedback-<timestamp>.tgz)
|
|
39
|
+
--help Show this help
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// Keys whose string values are free UI/business text.
|
|
43
|
+
const TEXT_KEYS = new Set([
|
|
44
|
+
"text", "contentDescription", "hint", "title", "label", "tooltipText",
|
|
45
|
+
"pattern", "tag", "event", "literal", "inputText"
|
|
46
|
+
]);
|
|
47
|
+
// Keys whose whole subtree holds business values.
|
|
48
|
+
const VALUE_TREE_KEYS = new Set(["fields", "values", "captures", "bindings"]);
|
|
49
|
+
const SERIAL_KEYS = new Set(["deviceSerial", "serial", "device"]);
|
|
50
|
+
// Keys holding diagnostic sentences that may quote UI text.
|
|
51
|
+
const FREE_TEXT_KEYS = new Set([
|
|
52
|
+
"message", "reason", "detail", "details", "error", "summary", "diagnostic",
|
|
53
|
+
"description", "remediation", "stderr", "stdout", "hint"
|
|
54
|
+
]);
|
|
55
|
+
// Packages kept verbatim: they describe the platform, not the app.
|
|
56
|
+
const PUBLIC_PREFIXES = [
|
|
57
|
+
"android.", "androidx.", "com.android.", "com.google.android.", "java.",
|
|
58
|
+
"kotlin.", "dalvik.", "io.appium.", "com.github.uiautomator"
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const QUALIFIED = /^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z_$][\w$]*$/;
|
|
62
|
+
const RESOURCE_ID = /^([A-Za-z_][\w.]*):(id|string|drawable|layout)\/(.+)$/;
|
|
63
|
+
const HEX_HASH = /^[a-f\d]{40,64}$/;
|
|
64
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$/;
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const options = {
|
|
68
|
+
project: process.cwd(), package: undefined, orgPrefixes: [],
|
|
69
|
+
generations: [], runs: [], out: undefined, help: false
|
|
70
|
+
};
|
|
71
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
72
|
+
const flag = argv[index];
|
|
73
|
+
const value = () => {
|
|
74
|
+
const next = argv[index + 1];
|
|
75
|
+
if (next === undefined || next.startsWith("--")) {
|
|
76
|
+
fail(`${flag} requires a value`);
|
|
77
|
+
}
|
|
78
|
+
index += 1;
|
|
79
|
+
return next;
|
|
80
|
+
};
|
|
81
|
+
switch (flag) {
|
|
82
|
+
case "--project": options.project = value(); break;
|
|
83
|
+
case "--package": options.package = value(); break;
|
|
84
|
+
case "--org-prefix": options.orgPrefixes.push(value()); break;
|
|
85
|
+
case "--generation": options.generations.push(value()); break;
|
|
86
|
+
case "--run": options.runs.push(value()); break;
|
|
87
|
+
case "--out": options.out = value(); break;
|
|
88
|
+
case "--help": case "-h": options.help = true; break;
|
|
89
|
+
default: fail(`Unknown option ${flag}\n\n${HELP}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
options.project = resolve(options.project);
|
|
93
|
+
return options;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fail(message) {
|
|
97
|
+
process.stderr.write(`error: ${message}\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readPackage(projectRoot) {
|
|
102
|
+
const configPath = join(projectRoot, ".taphound", "config.json");
|
|
103
|
+
if (!existsSync(configPath)) return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(configPath, "utf8"))?.run?.packageName;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listJsonFiles(directory) {
|
|
112
|
+
const files = [];
|
|
113
|
+
const walk = (current) => {
|
|
114
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
115
|
+
const path = join(current, entry.name);
|
|
116
|
+
if (entry.isDirectory()) walk(path);
|
|
117
|
+
// Skip macOS AppleDouble "._*" companions of copied files.
|
|
118
|
+
else if (entry.isFile() && entry.name.endsWith(".json")
|
|
119
|
+
&& !entry.name.startsWith("._")) files.push(path);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
walk(directory);
|
|
123
|
+
return files.sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectSources(options) {
|
|
127
|
+
const buildRoot = join(options.project, ".taphound", "build");
|
|
128
|
+
const generationsRoot = join(buildRoot, "generations");
|
|
129
|
+
if (!existsSync(generationsRoot)) {
|
|
130
|
+
fail(`No generations directory at ${generationsRoot}`);
|
|
131
|
+
}
|
|
132
|
+
const wanted = new Set(options.generations);
|
|
133
|
+
const bundles = readdirSync(generationsRoot, { withFileTypes: true })
|
|
134
|
+
.filter((entry) => entry.isDirectory() && entry.name !== ".locks")
|
|
135
|
+
.filter((entry) => {
|
|
136
|
+
const id = entry.name.replace(/^\.(.+)\.work$/, "$1");
|
|
137
|
+
return wanted.size === 0 || wanted.has(id);
|
|
138
|
+
})
|
|
139
|
+
.map((entry) => join(generationsRoot, entry.name));
|
|
140
|
+
if (bundles.length === 0) fail("No matching generation bundles found");
|
|
141
|
+
|
|
142
|
+
const files = bundles.flatMap(listJsonFiles);
|
|
143
|
+
for (const runId of options.runs) {
|
|
144
|
+
const report = join(buildRoot, "runs", runId, "report.json");
|
|
145
|
+
if (!existsSync(report)) fail(`Run report not found: ${report}`);
|
|
146
|
+
files.push(report);
|
|
147
|
+
const verdict = join(buildRoot, "runs", runId, "verdict.json");
|
|
148
|
+
if (existsSync(verdict)) files.push(verdict);
|
|
149
|
+
}
|
|
150
|
+
return { files, bundles };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class Redactor {
|
|
154
|
+
constructor({ packageName, orgPrefixes, projectRoot }) {
|
|
155
|
+
this.appPackage = packageName;
|
|
156
|
+
this.prefixes = [...new Set([
|
|
157
|
+
packageName,
|
|
158
|
+
packageName.split(".").slice(0, 2).join("."),
|
|
159
|
+
...orgPrefixes
|
|
160
|
+
])].filter((prefix) => prefix.includes("."));
|
|
161
|
+
this.paths = [[projectRoot, "<project>"], [homedir(), "~"]]
|
|
162
|
+
.filter(([path]) => path.length > 1);
|
|
163
|
+
this.maps = {
|
|
164
|
+
package: new Map([[packageName, "com.example.app"]]),
|
|
165
|
+
class: new Map(),
|
|
166
|
+
resource: new Map(),
|
|
167
|
+
text: new Map(),
|
|
168
|
+
resourceName: new Map(),
|
|
169
|
+
serial: new Map()
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
isPrivateName(value) {
|
|
174
|
+
if (PUBLIC_PREFIXES.some((prefix) => value.startsWith(prefix))) return false;
|
|
175
|
+
return this.prefixes.some(
|
|
176
|
+
(prefix) => value === prefix || value.startsWith(`${prefix}.`)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pseudo(kind, value, make) {
|
|
181
|
+
const map = this.maps[kind];
|
|
182
|
+
if (!map.has(value)) map.set(value, make(map.size + 1));
|
|
183
|
+
return map.get(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
packageFor(value) {
|
|
187
|
+
if (this.maps.package.has(value)) return this.maps.package.get(value);
|
|
188
|
+
if (value.startsWith(`${this.appPackage}.`)) return undefined;
|
|
189
|
+
return this.pseudo("package", value, (n) => `com.example.other${n}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
classFor(value) {
|
|
193
|
+
if (this.maps.package.has(value)) return this.maps.package.get(value);
|
|
194
|
+
return this.pseudo("class", value, (n) => {
|
|
195
|
+
const last = value.split(".").at(-1) ?? "";
|
|
196
|
+
const kind = /Activity$/.test(last) ? "Activity"
|
|
197
|
+
: /Fragment$/.test(last) ? "Fragment" : "Class";
|
|
198
|
+
return `com.example.app.${kind}${n}`;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Pass 1: learn every sensitive value.
|
|
203
|
+
learn(value, key, inValueTree) {
|
|
204
|
+
if (Array.isArray(value)) {
|
|
205
|
+
for (const item of value) this.learn(item, key, inValueTree);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (value !== null && typeof value === "object") {
|
|
209
|
+
for (const [childKey, child] of Object.entries(value)) {
|
|
210
|
+
this.learn(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey));
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (typeof value !== "string" || value.length === 0) return;
|
|
215
|
+
if (SERIAL_KEYS.has(key) && !QUALIFIED.test(value)) {
|
|
216
|
+
this.pseudo("serial", value, (n) => `device-${n}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (key === "resourceId" && !value.includes(":")) {
|
|
220
|
+
// Some UI backends report bare resource names (e.g. "btn_submit").
|
|
221
|
+
this.pseudo("resourceName", value, (n) => `r${n}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const resource = RESOURCE_ID.exec(value);
|
|
225
|
+
if (resource !== null) {
|
|
226
|
+
const [, owner, type] = resource;
|
|
227
|
+
if (owner !== "android" && !PUBLIC_PREFIXES.some((p) => `${owner}.`.startsWith(p))) {
|
|
228
|
+
this.pseudo("resource", value, (n) => {
|
|
229
|
+
const pkg = owner === this.appPackage ? "com.example.app" : "com.example.other";
|
|
230
|
+
return `${pkg}:${type}/r${n}`;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (QUALIFIED.test(value) && this.isPrivateName(value)) {
|
|
236
|
+
if (key.toLowerCase().includes("package")) this.packageFor(value);
|
|
237
|
+
else this.classFor(value);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (TEXT_KEYS.has(key) || inValueTree) {
|
|
241
|
+
if (HEX_HASH.test(value) || ISO_DATE.test(value)) return;
|
|
242
|
+
this.pseudo("text", value, (n) => `T${n}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
finish() {
|
|
247
|
+
// Longest originals first so a substring never shadows its container.
|
|
248
|
+
const byLength = (a, b) => b[0].length - a[0].length;
|
|
249
|
+
const names = [...this.paths];
|
|
250
|
+
for (const kind of ["serial", "resource", "class", "package"]) {
|
|
251
|
+
for (const pair of this.maps[kind]) names.push(pair);
|
|
252
|
+
}
|
|
253
|
+
this.nameSubstrings = names.sort(byLength);
|
|
254
|
+
this.textSubstrings = [...this.maps.text, ...this.maps.resourceName]
|
|
255
|
+
.filter(([original]) => original.length >= 3)
|
|
256
|
+
.sort(byLength);
|
|
257
|
+
this.exactNames = new Map(names);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pass 2: rewrite values. Keys stay verbatim so the schema remains readable.
|
|
261
|
+
rewrite(value, key = "", inValueTree = false) {
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
return value.map((item) => this.rewrite(item, key, inValueTree));
|
|
264
|
+
}
|
|
265
|
+
if (value !== null && typeof value === "object") {
|
|
266
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
|
|
267
|
+
childKey,
|
|
268
|
+
this.rewrite(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey))
|
|
269
|
+
]));
|
|
270
|
+
}
|
|
271
|
+
if (typeof value !== "string") return value;
|
|
272
|
+
if (this.exactNames.has(value)) return this.exactNames.get(value);
|
|
273
|
+
if (key === "resourceId" && this.maps.resourceName.has(value)) {
|
|
274
|
+
return this.maps.resourceName.get(value);
|
|
275
|
+
}
|
|
276
|
+
if ((TEXT_KEYS.has(key) || inValueTree) && this.maps.text.has(value)) {
|
|
277
|
+
return this.maps.text.get(value);
|
|
278
|
+
}
|
|
279
|
+
const withNames = this.rewriteString(value, this.nameSubstrings);
|
|
280
|
+
return FREE_TEXT_KEYS.has(key)
|
|
281
|
+
? this.rewriteString(withNames, this.textSubstrings)
|
|
282
|
+
: withNames;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
rewriteString(value, pairs = this.nameSubstrings) {
|
|
286
|
+
// Placeholders keep one replacement's output from matching another original.
|
|
287
|
+
let result = value;
|
|
288
|
+
const pending = [];
|
|
289
|
+
for (const [original, replacement] of pairs) {
|
|
290
|
+
if (!result.includes(original)) continue;
|
|
291
|
+
const token = `\u0000${pending.length}\u0000`;
|
|
292
|
+
result = result.split(original).join(token);
|
|
293
|
+
pending.push([token, replacement]);
|
|
294
|
+
}
|
|
295
|
+
for (const [token, replacement] of pending) {
|
|
296
|
+
result = result.split(token).join(replacement);
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
mapping() {
|
|
302
|
+
return Object.fromEntries(Object.entries(this.maps).map(
|
|
303
|
+
([kind, map]) => [kind, Object.fromEntries(
|
|
304
|
+
[...map].map(([original, replacement]) => [replacement, original])
|
|
305
|
+
)]
|
|
306
|
+
));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
counts() {
|
|
310
|
+
return Object.fromEntries(Object.entries(this.maps).map(
|
|
311
|
+
([kind, map]) => [kind, map.size]
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function main() {
|
|
317
|
+
const options = parseArgs(process.argv.slice(2));
|
|
318
|
+
if (options.help) {
|
|
319
|
+
process.stdout.write(HELP);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const packageName = options.package ?? readPackage(options.project);
|
|
323
|
+
if (typeof packageName !== "string" || !QUALIFIED.test(packageName)) {
|
|
324
|
+
fail("Cannot determine the app package; pass --package <pkg>");
|
|
325
|
+
}
|
|
326
|
+
const { files, bundles } = collectSources(options);
|
|
327
|
+
const redactor = new Redactor({
|
|
328
|
+
packageName, orgPrefixes: options.orgPrefixes, projectRoot: options.project
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const documents = [];
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
try {
|
|
334
|
+
documents.push([file, JSON.parse(readFileSync(file, "utf8"))]);
|
|
335
|
+
} catch {
|
|
336
|
+
process.stderr.write(`skip (not valid JSON): ${relative(options.project, file)}\n`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [, document] of documents) redactor.learn(document, "", false);
|
|
340
|
+
redactor.finish();
|
|
341
|
+
|
|
342
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
343
|
+
// The diagnostics directory sits in the Git-ignored build subtree.
|
|
344
|
+
const out = resolve(options.out ?? join(
|
|
345
|
+
options.project, ".taphound", "build", "diagnostics",
|
|
346
|
+
`taphound-feedback-${stamp}.tgz`
|
|
347
|
+
));
|
|
348
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
349
|
+
const staging = mkdtempSync(join(tmpdir(), "taphound-feedback-"));
|
|
350
|
+
const root = join(staging, "taphound-feedback");
|
|
351
|
+
try {
|
|
352
|
+
for (const [file, document] of documents) {
|
|
353
|
+
const relativePath = redactor.rewriteString(
|
|
354
|
+
relative(options.project, file).split(sep).join("/")
|
|
355
|
+
);
|
|
356
|
+
const target = join(root, ...relativePath.split("/"));
|
|
357
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
358
|
+
writeFileSync(target, `${JSON.stringify(redactor.rewrite(document), null, 2)}\n`);
|
|
359
|
+
}
|
|
360
|
+
writeFileSync(join(root, "README.txt"), [
|
|
361
|
+
"TapHound feedback bundle (redacted).",
|
|
362
|
+
`Generation bundles: ${bundles.length}`,
|
|
363
|
+
`JSON files: ${documents.length}`,
|
|
364
|
+
`Pseudonyms: ${JSON.stringify(redactor.counts())}`,
|
|
365
|
+
"Screenshots, Logcat text, and non-JSON files are excluded.",
|
|
366
|
+
""
|
|
367
|
+
].join("\n"));
|
|
368
|
+
|
|
369
|
+
const tar = spawnSync(
|
|
370
|
+
"tar", ["-czf", out, "-C", staging, "taphound-feedback"],
|
|
371
|
+
{
|
|
372
|
+
shell: false,
|
|
373
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
374
|
+
// Stop macOS bsdtar from adding AppleDouble "._*" xattr entries.
|
|
375
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" }
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
if (tar.error !== undefined || tar.status !== 0) {
|
|
379
|
+
fail(`tar failed${tar.error ? `: ${tar.error.message}` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
rmSync(staging, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const mappingPath = out.replace(/\.t(?:ar\.)?gz$/, "") + ".mapping.json";
|
|
386
|
+
writeFileSync(mappingPath, `${JSON.stringify(redactor.mapping(), null, 2)}\n`);
|
|
387
|
+
|
|
388
|
+
process.stdout.write([
|
|
389
|
+
`Archive: ${out} (${statSync(out).size} bytes)`,
|
|
390
|
+
`Mapping: ${mappingPath} <- keep this local, do NOT share`,
|
|
391
|
+
`Bundles: ${bundles.map((bundle) => basename(bundle)).join(", ")}`,
|
|
392
|
+
`Files: ${documents.length} JSON`,
|
|
393
|
+
`Redacted: ${JSON.stringify(redactor.counts())}`,
|
|
394
|
+
"Review: tar -xzf <archive> and grep for anything still sensitive before sharing.",
|
|
395
|
+
""
|
|
396
|
+
].join("\n"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
main();
|