@olomonhelpdesk2/helpdesk 0.1.4 → 0.1.9
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/helpdesk-capture.mjs +379 -152
- package/helpdesk-seed.mjs +21 -13
- package/helpdesk.mjs +218 -24
- package/package.json +1 -1
package/helpdesk-seed.mjs
CHANGED
|
@@ -219,7 +219,7 @@ function gapReport(gaps2) {
|
|
|
219
219
|
""
|
|
220
220
|
];
|
|
221
221
|
for (const gap of gaps2) {
|
|
222
|
-
const broke = gap.broke.length ? `
|
|
222
|
+
const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
|
|
223
223
|
lines.push(` ${gap.method} ${gap.path}${broke}`);
|
|
224
224
|
if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
|
|
225
225
|
}
|
|
@@ -733,6 +733,8 @@ var ALREADY_SAFE = [
|
|
|
733
733
|
function alreadySafe(kind, value) {
|
|
734
734
|
return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
|
|
735
735
|
}
|
|
736
|
+
var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
|
|
737
|
+
var LABEL_REACH = 48;
|
|
736
738
|
var PATTERNS = [
|
|
737
739
|
{
|
|
738
740
|
kind: "email",
|
|
@@ -782,13 +784,14 @@ var PATTERNS = [
|
|
|
782
784
|
{
|
|
783
785
|
kind: "date-of-birth",
|
|
784
786
|
regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
|
|
785
|
-
because: "a
|
|
786
|
-
replace: () => "1970-01-01"
|
|
787
|
+
because: "a date labeled as a birth date, which identifies somebody",
|
|
788
|
+
replace: () => "1970-01-01",
|
|
789
|
+
labelled: BIRTH_LABEL
|
|
787
790
|
},
|
|
788
791
|
{
|
|
789
792
|
kind: "passport",
|
|
790
793
|
regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
|
|
791
|
-
because: "the shape of a passport or
|
|
794
|
+
because: "the shape of a passport or license number",
|
|
792
795
|
replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
|
|
793
796
|
},
|
|
794
797
|
{
|
|
@@ -804,8 +807,13 @@ function scrub(text, options = {}) {
|
|
|
804
807
|
let output = text;
|
|
805
808
|
for (const pattern of PATTERNS) {
|
|
806
809
|
const seen = /* @__PURE__ */ new Map();
|
|
807
|
-
output = output.replace(pattern.regex, (
|
|
810
|
+
output = output.replace(pattern.regex, (...args) => {
|
|
811
|
+
const match = args[0];
|
|
812
|
+
const named = typeof args[args.length - 1] === "object";
|
|
813
|
+
const offset = args[args.length - (named ? 3 : 2)];
|
|
814
|
+
const whole = args[args.length - (named ? 2 : 1)];
|
|
808
815
|
if (alreadySafe(pattern.kind, match)) return match;
|
|
816
|
+
if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
|
|
809
817
|
const already = seen.get(match);
|
|
810
818
|
if (already) return already;
|
|
811
819
|
const replacement = pattern.replace(match, seen.size);
|
|
@@ -822,7 +830,7 @@ function scrub(text, options = {}) {
|
|
|
822
830
|
found: brand,
|
|
823
831
|
replacement: brand,
|
|
824
832
|
where,
|
|
825
|
-
because: "a real institution in a published screenshot implies a partnership
|
|
833
|
+
because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
|
|
826
834
|
});
|
|
827
835
|
}
|
|
828
836
|
}
|
|
@@ -838,7 +846,7 @@ function describeScrub(result) {
|
|
|
838
846
|
}
|
|
839
847
|
const lines = [`${result.findings.length} thing(s) found. Nothing publishes until you confirm.`, ""];
|
|
840
848
|
for (const [kind, group] of byKind) {
|
|
841
|
-
lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}
|
|
849
|
+
lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}: ${group[0].because}`);
|
|
842
850
|
for (const finding of group.slice(0, 5)) {
|
|
843
851
|
lines.push(
|
|
844
852
|
kind === "brand" ? ` ${finding.found} (left as-is: decide whether this frame can ship)` : ` ${finding.found} \u2192 ${finding.replacement}`
|
|
@@ -1217,7 +1225,7 @@ async function record(args) {
|
|
|
1217
1225
|
[
|
|
1218
1226
|
`Recording everything sent to ${args.upstream}.`,
|
|
1219
1227
|
"",
|
|
1220
|
-
"Point your product's backend address at this and use it
|
|
1228
|
+
"Point your product's backend address at this and use it: sign in, open the screens",
|
|
1221
1229
|
"you want documented, and make the errors happen if you can. What you visit is what",
|
|
1222
1230
|
"the recording will be able to replay.",
|
|
1223
1231
|
"",
|
|
@@ -1270,7 +1278,7 @@ ${carriedReport(sealed.carried, args.cassette).join("\n")}`);
|
|
|
1270
1278
|
console.log(`
|
|
1271
1279
|
${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
|
|
1272
1280
|
console.log(
|
|
1273
|
-
"\nThese survived the field pass, which means they were in free text or under a key nothing\
|
|
1281
|
+
"\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognized. Read them before you commit this."
|
|
1274
1282
|
);
|
|
1275
1283
|
return 1;
|
|
1276
1284
|
}
|
|
@@ -1278,7 +1286,7 @@ ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}
|
|
|
1278
1286
|
return 0;
|
|
1279
1287
|
}
|
|
1280
1288
|
function carriedReport(carried, cassette) {
|
|
1281
|
-
if (!carried.length) return ["Every key in the recording was
|
|
1289
|
+
if (!carried.length) return ["Every key in the recording was recognized and rewritten."];
|
|
1282
1290
|
const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
|
|
1283
1291
|
const width = Math.max(...ranked.map((field) => field.key.length));
|
|
1284
1292
|
const worth = ranked.filter((field) => score(field) > 0);
|
|
@@ -1289,7 +1297,7 @@ function carriedReport(carried, cassette) {
|
|
|
1289
1297
|
(field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
|
|
1290
1298
|
),
|
|
1291
1299
|
"",
|
|
1292
|
-
"That is not automatically wrong
|
|
1300
|
+
"That is not automatically wrong: an id, a status or a currency is meant to survive.",
|
|
1293
1301
|
"But this package only knows the words every product shares: people, their contact",
|
|
1294
1302
|
"details, their address, and the role each one is playing. Whatever your product holds",
|
|
1295
1303
|
"that no other product does is on the list above, and it was not touched.",
|
|
@@ -1304,7 +1312,7 @@ function carriedReport(carried, cassette) {
|
|
|
1304
1312
|
" company, note, amount",
|
|
1305
1313
|
"",
|
|
1306
1314
|
" note invents a sentence of about the same length, for anything free-text.",
|
|
1307
|
-
" amount invents a different figure of the same size, for a salary or a balance
|
|
1315
|
+
" amount invents a different figure of the same size, for a salary or a balance:",
|
|
1308
1316
|
" a number is never detectable by shape, so naming it is the only way it is caught."
|
|
1309
1317
|
] : []
|
|
1310
1318
|
];
|
|
@@ -1380,7 +1388,7 @@ async function routes(args) {
|
|
|
1380
1388
|
const cassette = parseCassette(await readFile(args.cassette, "utf8"));
|
|
1381
1389
|
const found = exampleRoutes(cassette);
|
|
1382
1390
|
if (!found.length) {
|
|
1383
|
-
console.log("The recording holds no
|
|
1391
|
+
console.log("The recording holds no parameterized routes, so there are no example ids to offer.");
|
|
1384
1392
|
return 0;
|
|
1385
1393
|
}
|
|
1386
1394
|
console.log("Routes the recording can answer, with an identifier it has already served:\n");
|
package/helpdesk.mjs
CHANGED
|
@@ -188,7 +188,7 @@ function score(route, signals) {
|
|
|
188
188
|
}
|
|
189
189
|
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
190
190
|
total += 18;
|
|
191
|
-
because.push("money or access
|
|
191
|
+
because.push("money or access: asked about out of proportion to its traffic");
|
|
192
192
|
}
|
|
193
193
|
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
194
194
|
total += 16;
|
|
@@ -538,11 +538,22 @@ var LIMITS = {
|
|
|
538
538
|
fields: 24,
|
|
539
539
|
notices: 20,
|
|
540
540
|
warnings: 12,
|
|
541
|
+
disabled: 24,
|
|
542
|
+
empty: 12,
|
|
543
|
+
gatedBy: 8,
|
|
541
544
|
choices: 12,
|
|
542
545
|
/** Longer than this is a paragraph of copy, not a label. */
|
|
543
546
|
label: 120,
|
|
544
547
|
sentence: 300
|
|
545
548
|
};
|
|
549
|
+
var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
|
|
550
|
+
function isEmptyState(text) {
|
|
551
|
+
const sentence = text.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
|
|
552
|
+
if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
|
|
553
|
+
if (/\b(?:until|unless)\b/i.test(sentence)) return false;
|
|
554
|
+
if (sentence.split(" ").length < 2) return false;
|
|
555
|
+
return EMPTY_STATE.test(sentence);
|
|
556
|
+
}
|
|
546
557
|
var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
|
|
547
558
|
var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
|
|
548
559
|
var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
|
|
@@ -553,18 +564,24 @@ function readScreenFacts(files, options = {}) {
|
|
|
553
564
|
const links = [];
|
|
554
565
|
const fields = [];
|
|
555
566
|
const notices = [];
|
|
556
|
-
const
|
|
567
|
+
const disabled = [];
|
|
568
|
+
const empty = [];
|
|
557
569
|
const messageDepth = options.messageDepth ?? 2;
|
|
570
|
+
const read = files.filter((file) => !options.skip?.(file.path));
|
|
571
|
+
const gatedBy = flagsGating(read, messageDepth);
|
|
558
572
|
for (const file of files) {
|
|
559
573
|
if (options.skip?.(file.path)) continue;
|
|
560
574
|
const source = withoutComments(file.contents);
|
|
561
|
-
const before = headings.length + actions.length + links.length + fields.length + notices.length;
|
|
562
|
-
readElements(source, { headings, actions, links, fields });
|
|
563
|
-
if ((file.depth ?? 0) <= messageDepth)
|
|
564
|
-
|
|
575
|
+
const before = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
|
|
576
|
+
readElements(source, { headings, actions, links, fields, disabled, empty });
|
|
577
|
+
if ((file.depth ?? 0) <= messageDepth) {
|
|
578
|
+
readMessages(source, notices);
|
|
579
|
+
readEmptyMessages(source, empty);
|
|
580
|
+
}
|
|
581
|
+
const after = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
|
|
565
582
|
if (after > before) facts.readFrom.push(file.path);
|
|
566
583
|
}
|
|
567
|
-
return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom });
|
|
584
|
+
return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom, disabled, empty, gatedBy });
|
|
568
585
|
}
|
|
569
586
|
function finishScreenFacts(parts) {
|
|
570
587
|
const warnings = [];
|
|
@@ -581,23 +598,48 @@ function finishScreenFacts(parts) {
|
|
|
581
598
|
fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
|
|
582
599
|
notices: capped(parts.notices, LIMITS.notices),
|
|
583
600
|
warnings: capped(warnings, LIMITS.warnings),
|
|
584
|
-
readFrom: parts.readFrom
|
|
601
|
+
readFrom: parts.readFrom,
|
|
602
|
+
...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function states(parts) {
|
|
606
|
+
const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
|
|
607
|
+
const empty = capped(parts.empty ?? [], LIMITS.empty);
|
|
608
|
+
const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
|
|
609
|
+
return {
|
|
610
|
+
...disabled.length ? { disabled } : {},
|
|
611
|
+
...empty.length ? { empty } : {},
|
|
612
|
+
...gatedBy.length ? { gatedBy } : {}
|
|
585
613
|
};
|
|
586
614
|
}
|
|
587
615
|
var HEADING_TAG = /^h[1-4]$/;
|
|
588
616
|
var BUTTON_TAG = /^(button|Button|MenuItem|Tab)$/;
|
|
589
617
|
var LINK_TAG = /^(a|Link|NavLink)$/;
|
|
590
618
|
var FIELD_TAG = /^(input|Input|textarea|Textarea|select|Select|Checkbox|Radio|Switch|Toggle)$/;
|
|
619
|
+
var TEXT_TAG = /^(p|span|small|td|li|div)$/;
|
|
620
|
+
var NOT_INLINE = /<(?!\/?(?:strong|em|b|i|code|br)\b)[A-Za-z]/;
|
|
621
|
+
var EMPTY_TAG = /^(EmptyState|Empty|EmptyMessage|EmptyPanel|NoResults|ZeroState|BlankState)$/;
|
|
622
|
+
var EMPTY_PROPS = ["emptyState", "emptyText", "emptyMessage", "emptyLabel", "emptyTitle", "emptyDescription"];
|
|
623
|
+
var DISABLED_PROP = /(?:^|\s)(?:disabled|aria-disabled)(?=\s|=|\/|$)(?!\s*=\s*\{\s*false\s*\})(?!\s*=\s*["']false["'])/;
|
|
591
624
|
function readElements(source, into) {
|
|
592
625
|
for (const tag of tagsIn(source)) {
|
|
593
626
|
const attributes = readAttributes(tag.attributes);
|
|
594
627
|
if (HEADING_TAG.test(tag.name)) {
|
|
595
|
-
|
|
628
|
+
const children = childrenOf(source, tag);
|
|
629
|
+
const heading = visibleText(children);
|
|
630
|
+
push(into.headings, heading);
|
|
631
|
+
for (const said of [heading, ...chosenIn(children)]) if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
|
|
596
632
|
continue;
|
|
597
633
|
}
|
|
598
634
|
push(into.headings, attributes["heading"]);
|
|
635
|
+
for (const prop of EMPTY_PROPS) pushWords(into.empty, attributes[prop]);
|
|
636
|
+
if (EMPTY_TAG.test(tag.name)) {
|
|
637
|
+
for (const prop of ["title", "description", "message"]) pushWords(into.empty, attributes[prop]);
|
|
638
|
+
}
|
|
599
639
|
if (BUTTON_TAG.test(tag.name)) {
|
|
600
|
-
|
|
640
|
+
const label = visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"];
|
|
641
|
+
push(into.actions, label);
|
|
642
|
+
if (DISABLED_PROP.test(tag.attributes)) push(into.disabled, label);
|
|
601
643
|
continue;
|
|
602
644
|
}
|
|
603
645
|
if (LINK_TAG.test(tag.name)) {
|
|
@@ -611,6 +653,16 @@ function readElements(source, into) {
|
|
|
611
653
|
if (FIELD_TAG.test(tag.name)) {
|
|
612
654
|
const field = fieldFrom(tag.name, attributes, source, tag);
|
|
613
655
|
if (field) into.fields.push(field);
|
|
656
|
+
if (field && DISABLED_PROP.test(tag.attributes)) push(into.disabled, field.label);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (TEXT_TAG.test(tag.name)) {
|
|
660
|
+
const children = childrenOf(source, tag);
|
|
661
|
+
if (NOT_INLINE.test(children)) continue;
|
|
662
|
+
const written = visibleText(children, LIMITS.sentence).replace(/\s+([.,;:!])/g, "$1");
|
|
663
|
+
for (const said of [written, ...chosenIn(children)]) {
|
|
664
|
+
if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
|
|
665
|
+
}
|
|
614
666
|
}
|
|
615
667
|
}
|
|
616
668
|
for (const tag of tagsIn(source)) {
|
|
@@ -723,7 +775,7 @@ function childrenOf(source, tag) {
|
|
|
723
775
|
function escapeName(name) {
|
|
724
776
|
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
725
777
|
}
|
|
726
|
-
function visibleText(fragment) {
|
|
778
|
+
function visibleText(fragment, limit = LIMITS.label) {
|
|
727
779
|
if (!fragment) return "";
|
|
728
780
|
let text = fragment;
|
|
729
781
|
let previous = "";
|
|
@@ -732,7 +784,26 @@ function visibleText(fragment) {
|
|
|
732
784
|
text = text.replace(/\{[^{}]*\}/g, " ");
|
|
733
785
|
}
|
|
734
786
|
text = text.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/—/g, "\u2014").replace(/–/g, "\u2013").replace(/&(?:l|r)squo;/g, "'").replace(/&(?:l|r)dquo;/g, '"').replace(/&#\d+;/g, " ").replace(/\s+/g, " ").trim();
|
|
735
|
-
return /[A-Za-z]/.test(text) ? trim(text,
|
|
787
|
+
return /[A-Za-z]/.test(text) ? trim(text, limit) : "";
|
|
788
|
+
}
|
|
789
|
+
function chosenIn(fragment) {
|
|
790
|
+
const chosen = [];
|
|
791
|
+
let depth2 = 0;
|
|
792
|
+
let start = 0;
|
|
793
|
+
for (let index = 0; index < fragment.length; index += 1) {
|
|
794
|
+
if (fragment[index] === "{") {
|
|
795
|
+
if (depth2 === 0) start = index + 1;
|
|
796
|
+
depth2 += 1;
|
|
797
|
+
} else if (fragment[index] === "}" && depth2 > 0) {
|
|
798
|
+
depth2 -= 1;
|
|
799
|
+
if (depth2 > 0) continue;
|
|
800
|
+
for (const literal of fragment.slice(start, index).matchAll(/"([^"\\]*)"|'([^'\\]*)'|`([^`\\$]*)`/g)) {
|
|
801
|
+
const text = (literal[1] ?? literal[2] ?? literal[3] ?? "").replace(/\s+/g, " ").trim();
|
|
802
|
+
if (text) chosen.push(text);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return chosen;
|
|
736
807
|
}
|
|
737
808
|
function readAttributes(raw) {
|
|
738
809
|
const attributes = {};
|
|
@@ -752,11 +823,80 @@ function humanise(name) {
|
|
|
752
823
|
function scrubExpressions(value) {
|
|
753
824
|
return value.replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
|
|
754
825
|
}
|
|
755
|
-
function push(into, value) {
|
|
826
|
+
function push(into, value, limit = LIMITS.label) {
|
|
756
827
|
const text = value === void 0 ? "" : scrubExpressions(value);
|
|
757
828
|
if (!text || !/[A-Za-z]/.test(text)) return;
|
|
758
829
|
if ((text.match(/…/g)?.length ?? 0) > 1) return;
|
|
759
|
-
into.push(trim(text,
|
|
830
|
+
into.push(trim(text, limit));
|
|
831
|
+
}
|
|
832
|
+
function pushWords(into, value) {
|
|
833
|
+
if (value && value.trim().split(/\s+/).length >= 2) push(into, value, LIMITS.sentence);
|
|
834
|
+
}
|
|
835
|
+
var EMPTY_PROPERTY = /(?:^|[\s,{(])(?:emptyState|emptyText|emptyMessage|emptyLabel|emptyTitle|emptyDescription)\s*:\s*(["'`])((?:(?!\1)[\s\S]){4,}?)\1/g;
|
|
836
|
+
function readEmptyMessages(source, into) {
|
|
837
|
+
for (const match of source.matchAll(EMPTY_PROPERTY)) pushWords(into, match[2].replace(/\s+/g, " ").trim());
|
|
838
|
+
}
|
|
839
|
+
var FLAG_NAME = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:ENABLED|ENABLE|FLAG|FEATURE)|(?:ENABLE|FEATURE|FLAG)_[A-Z0-9]+(?:_[A-Z0-9]+)*)\b/g;
|
|
840
|
+
var FLAG_NAME_ONLY = /^[A-Za-z0-9_.:-]{1,80}$/;
|
|
841
|
+
var DECLARATION = /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=>/gm;
|
|
842
|
+
var NOT_THERE = /\b(?:notFound|redirect)\s*\(/g;
|
|
843
|
+
function flagsIn(text) {
|
|
844
|
+
return [...new Set([...text.matchAll(FLAG_NAME)].map((match) => match[1]))];
|
|
845
|
+
}
|
|
846
|
+
function flagsGating(files, messageDepth) {
|
|
847
|
+
const named = /* @__PURE__ */ new Map();
|
|
848
|
+
for (const file of files) {
|
|
849
|
+
if ((file.depth ?? 0) > messageDepth) continue;
|
|
850
|
+
const source = withoutComments(file.contents);
|
|
851
|
+
const declarations = [...source.matchAll(DECLARATION)];
|
|
852
|
+
declarations.forEach((declaration, index) => {
|
|
853
|
+
const name = declaration[1] ?? declaration[2];
|
|
854
|
+
const body = source.slice(declaration.index, declarations[index + 1]?.index ?? source.length);
|
|
855
|
+
const flags = flagsIn(body);
|
|
856
|
+
if (flags.length) named.set(name, /* @__PURE__ */ new Set([...named.get(name) ?? [], ...flags]));
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const gating = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const file of files) {
|
|
861
|
+
if ((file.depth ?? 0) > 0) continue;
|
|
862
|
+
const source = withoutComments(file.contents);
|
|
863
|
+
for (const call of source.matchAll(NOT_THERE)) {
|
|
864
|
+
const condition = guardOf(source, call.index);
|
|
865
|
+
if (condition === void 0) continue;
|
|
866
|
+
for (const flag of flagsIn(condition)) gating.add(flag);
|
|
867
|
+
for (const [, dotted, bracketed] of condition.matchAll(
|
|
868
|
+
/process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\])/g
|
|
869
|
+
)) {
|
|
870
|
+
gating.add(dotted ?? bracketed);
|
|
871
|
+
}
|
|
872
|
+
for (const [, args] of condition.matchAll(/\b\w*(?:[Ff]lag|[Ff]eature|[Ee]nabled)\w*\s*\(([^()]*)\)/g)) {
|
|
873
|
+
for (const [, literal] of args.matchAll(/["'`]([^"'`]+)["'`]/g)) {
|
|
874
|
+
if (FLAG_NAME_ONLY.test(literal)) gating.add(literal);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
for (const [, callee] of condition.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
878
|
+
for (const flag of named.get(callee) ?? []) gating.add(flag);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return [...gating];
|
|
883
|
+
}
|
|
884
|
+
function guardOf(source, callAt) {
|
|
885
|
+
const before = source.slice(Math.max(0, callAt - 400), callAt);
|
|
886
|
+
const opened = [...before.matchAll(/\bif\s*\(/g)].at(-1);
|
|
887
|
+
if (!opened) return void 0;
|
|
888
|
+
const start = opened.index + opened[0].length;
|
|
889
|
+
let depth2 = 1;
|
|
890
|
+
let cursor = start;
|
|
891
|
+
while (cursor < before.length && depth2 > 0) {
|
|
892
|
+
const character = before[cursor];
|
|
893
|
+
if (character === "(") depth2 += 1;
|
|
894
|
+
else if (character === ")") depth2 -= 1;
|
|
895
|
+
cursor += 1;
|
|
896
|
+
}
|
|
897
|
+
if (depth2 !== 0) return void 0;
|
|
898
|
+
if (!/^\s*(?:\{\s*)?(?:return\s+)?$/.test(before.slice(cursor))) return void 0;
|
|
899
|
+
return before.slice(start, cursor - 1);
|
|
760
900
|
}
|
|
761
901
|
function trim(value, limit) {
|
|
762
902
|
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
@@ -793,6 +933,7 @@ function readProduct(routes, options) {
|
|
|
793
933
|
};
|
|
794
934
|
const documentable = routes.filter((route) => !route.skipped);
|
|
795
935
|
const total = Math.min(documentable.length, maxScreens);
|
|
936
|
+
const reading = { how: "source", commit: options.commit ?? null };
|
|
796
937
|
let done = 0;
|
|
797
938
|
let withFacts = 0;
|
|
798
939
|
const out = routes.map((route) => {
|
|
@@ -803,18 +944,23 @@ function readProduct(routes, options) {
|
|
|
803
944
|
const source = read(reached.path);
|
|
804
945
|
if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
|
|
805
946
|
}
|
|
806
|
-
const facts = readScreenFacts(files);
|
|
807
947
|
done += 1;
|
|
808
948
|
options.onScreen?.(route, done, total);
|
|
809
|
-
if (
|
|
949
|
+
if (files.length === 0) return route;
|
|
950
|
+
const facts = readScreenFacts(files);
|
|
951
|
+
if (!hasAnything(facts)) {
|
|
952
|
+
return { ...route, facts: noScreenFacts(reading) };
|
|
953
|
+
}
|
|
810
954
|
withFacts += 1;
|
|
811
|
-
return { ...route, facts };
|
|
955
|
+
return { ...route, facts: { ...facts, read: reading } };
|
|
812
956
|
});
|
|
813
957
|
return { routes: out, read: withFacts, filesOpened: opened.size };
|
|
814
958
|
}
|
|
815
959
|
function hasAnything(facts) {
|
|
816
960
|
return Boolean(
|
|
817
|
-
facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length
|
|
961
|
+
facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length || // A flagged page whose screen is all in a component the reader cannot
|
|
962
|
+
// follow still says it is behind a flag, and an empty state is still words.
|
|
963
|
+
facts.gatedBy?.length || facts.empty?.length
|
|
818
964
|
);
|
|
819
965
|
}
|
|
820
966
|
function describeReading(routes) {
|
|
@@ -823,6 +969,34 @@ function describeReading(routes) {
|
|
|
823
969
|
return { screens: documentable.length, read, pathsOnly: read === 0 };
|
|
824
970
|
}
|
|
825
971
|
|
|
972
|
+
// core/github/remote.ts
|
|
973
|
+
function repositoryFromRemote(remote) {
|
|
974
|
+
const raw = remote?.trim();
|
|
975
|
+
if (!raw) return void 0;
|
|
976
|
+
if (OWNER_NAME.test(raw)) return withoutGit(raw);
|
|
977
|
+
const scp = SCP.exec(raw);
|
|
978
|
+
const at = scp ? { host: scp[1], hostname: scp[1], path: scp[2] } : located(raw);
|
|
979
|
+
if (!at) return void 0;
|
|
980
|
+
const parts = withoutGit(at.path.replace(/^\/+|\/+$/g, "")).split("/").filter(Boolean);
|
|
981
|
+
if (!parts.length) return void 0;
|
|
982
|
+
if (GITHUB.test(at.hostname)) return parts.length === 2 ? `${parts[0]}/${parts[1]}` : void 0;
|
|
983
|
+
return `${at.host}/${parts.join("/")}`;
|
|
984
|
+
}
|
|
985
|
+
var OWNER_NAME = /^[A-Za-z0-9][\w-]*\/(?!\.\.?$)[\w.-]+$/;
|
|
986
|
+
var SCP = /^(?:[^@\s/]+@)?([^:\s/]{2,}):(?!\/)(\S+)$/;
|
|
987
|
+
var GITHUB = /^(www\.)?github\.com$/i;
|
|
988
|
+
function withoutGit(path) {
|
|
989
|
+
return path.replace(/\.git$/, "");
|
|
990
|
+
}
|
|
991
|
+
function located(raw) {
|
|
992
|
+
try {
|
|
993
|
+
const url = new URL(raw);
|
|
994
|
+
return url.hostname && /^(https?|ssh|git):$/.test(url.protocol) ? { host: url.host, hostname: url.hostname, path: url.pathname } : void 0;
|
|
995
|
+
} catch {
|
|
996
|
+
return void 0;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
826
1000
|
// core/markers/propose.ts
|
|
827
1001
|
function toLines(contents) {
|
|
828
1002
|
const lines = contents.split("\n");
|
|
@@ -937,7 +1111,7 @@ function markersPullRequestBody(result) {
|
|
|
937
1111
|
const lines = [
|
|
938
1112
|
"This adds one-line comments that link screens in this repository to the help articles that describe them.",
|
|
939
1113
|
"",
|
|
940
|
-
"**Nothing here changes
|
|
1114
|
+
"**Nothing here changes behavior.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
|
|
941
1115
|
"",
|
|
942
1116
|
`${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
|
|
943
1117
|
];
|
|
@@ -946,7 +1120,7 @@ function markersPullRequestBody(result) {
|
|
|
946
1120
|
}
|
|
947
1121
|
lines.push(
|
|
948
1122
|
"",
|
|
949
|
-
"**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route
|
|
1123
|
+
"**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route. This only makes the match more precise."
|
|
950
1124
|
);
|
|
951
1125
|
if (result.unmatchedSlugs.length > 0) {
|
|
952
1126
|
lines.push(
|
|
@@ -1000,7 +1174,10 @@ async function runMap(options) {
|
|
|
1000
1174
|
}
|
|
1001
1175
|
},
|
|
1002
1176
|
aliases: aliasesFromTsconfig(tsconfig),
|
|
1003
|
-
baseUrl: baseUrlFromTsconfig(tsconfig)
|
|
1177
|
+
baseUrl: baseUrlFromTsconfig(tsconfig),
|
|
1178
|
+
// Stamped onto every reading, so a map carries the commit its labels and
|
|
1179
|
+
// controls were read at and a later one can tell fresh from stale.
|
|
1180
|
+
commit: headCommit(options.cwd)
|
|
1004
1181
|
});
|
|
1005
1182
|
routes = read.routes;
|
|
1006
1183
|
const reading = describeReading(routes);
|
|
@@ -1024,7 +1201,7 @@ async function runMap(options) {
|
|
|
1024
1201
|
lines.push(
|
|
1025
1202
|
"",
|
|
1026
1203
|
proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
|
|
1027
|
-
git apply ${options.markersOut}` : "Every file already carries its marker
|
|
1204
|
+
git apply ${options.markersOut}` : "Every file already carries its marker: nothing to propose."
|
|
1028
1205
|
);
|
|
1029
1206
|
}
|
|
1030
1207
|
let sent = false;
|
|
@@ -1047,7 +1224,7 @@ async function runMap(options) {
|
|
|
1047
1224
|
throw new Error(`The help desk refused the map (${response.status}). ${body}`);
|
|
1048
1225
|
}
|
|
1049
1226
|
sent = true;
|
|
1050
|
-
lines.push("", "Sent. Whoever asked you to run this can see it now
|
|
1227
|
+
lines.push("", "Sent. Whoever asked you to run this can see it now. You are done.");
|
|
1051
1228
|
} else if (options.token) {
|
|
1052
1229
|
lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
|
|
1053
1230
|
}
|
|
@@ -1070,11 +1247,28 @@ function readTsconfig(cwd) {
|
|
|
1070
1247
|
}
|
|
1071
1248
|
function originOf(cwd) {
|
|
1072
1249
|
try {
|
|
1073
|
-
return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" })
|
|
1250
|
+
return repositoryFromRemote(execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }));
|
|
1074
1251
|
} catch {
|
|
1075
1252
|
return void 0;
|
|
1076
1253
|
}
|
|
1077
1254
|
}
|
|
1255
|
+
function headCommit(cwd) {
|
|
1256
|
+
try {
|
|
1257
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
|
|
1258
|
+
} catch {
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
var WORKFLOW_ACTIONS = {
|
|
1263
|
+
checkout: "actions/checkout@v7",
|
|
1264
|
+
setupNode: "actions/setup-node@v7"
|
|
1265
|
+
};
|
|
1266
|
+
var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
|
|
1267
|
+
- uses: ${WORKFLOW_ACTIONS.setupNode}
|
|
1268
|
+
with:
|
|
1269
|
+
node-version: "22"
|
|
1270
|
+
package-manager-cache: false
|
|
1271
|
+
`;
|
|
1078
1272
|
|
|
1079
1273
|
// adapters/invoked-as-command.ts
|
|
1080
1274
|
import { realpathSync } from "node:fs";
|