paperlint 10.4.0 → 10.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CLAUDE.md.spec.d.ts.map +1 -1
- package/dist/CLAUDE.md.spec.js +6 -3
- package/dist/CLAUDE.md.spec.js.map +1 -1
- package/dist/adapters/latex/captions.d.ts +9 -0
- package/dist/adapters/latex/captions.d.ts.map +1 -0
- package/dist/adapters/latex/captions.js +40 -0
- package/dist/adapters/latex/captions.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +1 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +6 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
- package/dist/adapters/node/list-entries.io.js +3 -1
- package/dist/adapters/node/list-entries.io.js.map +1 -1
- package/dist/domain/decide-first.d.ts +11 -1
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +6 -0
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/index-columns.d.ts.map +1 -1
- package/dist/domain/index-columns.js +2 -0
- package/dist/domain/index-columns.js.map +1 -1
- package/dist/domain/study-registry.d.ts +65 -8
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +63 -19
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +1 -1
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +21 -17
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/list-entries.d.ts +7 -4
- package/dist/ports/list-entries.d.ts.map +1 -1
- package/dist/ports/study-deps.d.ts +14 -0
- package/dist/ports/study-deps.d.ts.map +1 -0
- package/dist/ports/study-deps.js +2 -0
- package/dist/ports/study-deps.js.map +1 -0
- package/dist/ports/study-folder.d.ts +43 -0
- package/dist/ports/study-folder.d.ts.map +1 -0
- package/dist/ports/study-folder.js +2 -0
- package/dist/ports/study-folder.js.map +1 -0
- package/dist/presets.d.ts +1 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/presets.js.map +1 -1
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +10 -7
- package/dist/studies.js.map +1 -1
- package/dist/study-folder.d.ts +14 -0
- package/dist/study-folder.d.ts.map +1 -0
- package/dist/study-folder.js +123 -0
- package/dist/study-folder.js.map +1 -0
- package/dist/study-gate.d.ts +1 -1
- package/dist/study-gate.d.ts.map +1 -1
- package/dist/study-gate.js +65 -28
- package/dist/study-gate.js.map +1 -1
- package/dist/study-index-input.d.ts +1 -1
- package/dist/study-index-input.d.ts.map +1 -1
- package/dist/study-index-input.js +8 -3
- package/dist/study-index-input.js.map +1 -1
- package/dist/study-modes.d.ts +38 -27
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +42 -53
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts +2 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +32 -41
- package/dist/study-registry-rule.js.map +1 -1
- package/docs/design/study-modes.md +46 -28
- package/docs/rules/repro/outputs-before-decision.md +3 -0
- package/docs/rules/repro/study-registered.md +3 -2
- package/docs/rules/tex/heading-case.md +1 -1
- package/eslint.config.mjs +36 -24
- package/package.json +1 -1
- package/skills/grade-paper-writing/SKILL.md +4 -2
- package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
- package/skills/grade-paper-writing/prose-lint.mjs +127 -95
- package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
- package/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- package/src/adapters/latex/captions.test.ts +57 -0
- package/src/adapters/latex/captions.ts +56 -0
- package/src/adapters/latex/index.ts +1 -0
- package/src/adapters/latex/parse.ts +7 -0
- package/src/adapters/node/list-entries.io.ts +2 -1
- package/src/adapters/node/list-entries.test.ts +18 -0
- package/src/domain/decide-first.test.ts +33 -0
- package/src/domain/decide-first.ts +16 -1
- package/src/domain/index-columns.test.ts +49 -4
- package/src/domain/index-columns.ts +2 -0
- package/src/domain/study-measurements.test.ts +7 -3
- package/src/domain/study-registry.test.ts +34 -6
- package/src/domain/study-registry.ts +123 -31
- package/src/outputs-before-decision-rule.test.ts +47 -2
- package/src/outputs-before-decision-rule.ts +31 -22
- package/src/ports/list-entries.ts +7 -4
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/presets.ts +1 -2
- package/src/studies-index-modes.test.ts +2 -2
- package/src/studies.ts +19 -10
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +158 -7
- package/src/study-gate.ts +127 -54
- package/src/study-index-input.ts +12 -4
- package/src/study-modes.ts +67 -80
- package/src/study-registry-rule.test.ts +87 -0
- package/src/study-registry-rule.ts +58 -66
|
@@ -248,8 +248,22 @@ export type ParsedPlan =
|
|
|
248
248
|
}
|
|
249
249
|
| { readonly kind: "unreadable"; readonly why: string };
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* What a path inside a study's folder is, found without reading it or following a link: a regular
|
|
253
|
+
* file, nothing, or a path that passes through a symbolic link (`link`: the first one, relative to
|
|
254
|
+
* the folder).
|
|
255
|
+
*/
|
|
256
|
+
export type Located =
|
|
257
|
+
| { readonly kind: "file" }
|
|
258
|
+
| { readonly kind: "absent" }
|
|
259
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
260
|
+
|
|
251
261
|
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
252
|
-
export type PlanState =
|
|
262
|
+
export type PlanState =
|
|
263
|
+
| { readonly kind: "absent" }
|
|
264
|
+
/** The plan's path passes through a symbolic link, `link` (relative to the folder); never read. */
|
|
265
|
+
| { readonly kind: "linked"; readonly link: string }
|
|
266
|
+
| ParsedPlan;
|
|
253
267
|
|
|
254
268
|
/** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
|
|
255
269
|
export const decisionPath = (folder: string): string =>
|
|
@@ -303,8 +317,8 @@ export interface StudyInput {
|
|
|
303
317
|
readonly registry: RegistryState;
|
|
304
318
|
/** `repro/README.md`'s text; null when there is none. */
|
|
305
319
|
readonly readme: string | null;
|
|
306
|
-
/**
|
|
307
|
-
readonly
|
|
320
|
+
/** What each `<folder>/<result_file>` of the registry is, as `locate` found it; one not listed is absent. */
|
|
321
|
+
readonly results: ReadonlyMap<string, Located>;
|
|
308
322
|
/** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
|
|
309
323
|
readonly plans: ReadonlyMap<string, PlanState>;
|
|
310
324
|
}
|
|
@@ -351,6 +365,13 @@ export type StudyFinding =
|
|
|
351
365
|
readonly path: string;
|
|
352
366
|
readonly why: "missing" | "outside";
|
|
353
367
|
}
|
|
368
|
+
| {
|
|
369
|
+
readonly problem: "result-file";
|
|
370
|
+
readonly id: string;
|
|
371
|
+
readonly path: string;
|
|
372
|
+
readonly why: "linked";
|
|
373
|
+
readonly link: string;
|
|
374
|
+
}
|
|
354
375
|
| {
|
|
355
376
|
readonly problem: "no-successor";
|
|
356
377
|
readonly id: string;
|
|
@@ -390,6 +411,13 @@ export type StudyFinding =
|
|
|
390
411
|
readonly path: string;
|
|
391
412
|
readonly why: "missing" | "outside";
|
|
392
413
|
}
|
|
414
|
+
| {
|
|
415
|
+
readonly problem: "bad-plan";
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly path: string;
|
|
418
|
+
readonly why: "linked";
|
|
419
|
+
readonly link: string;
|
|
420
|
+
}
|
|
393
421
|
| {
|
|
394
422
|
readonly problem: "bad-plan";
|
|
395
423
|
readonly id: string;
|
|
@@ -479,24 +507,66 @@ export const isInside = (path: string): boolean => {
|
|
|
479
507
|
export const isFolderName = (folder: string): boolean =>
|
|
480
508
|
folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
|
|
481
509
|
|
|
510
|
+
/**
|
|
511
|
+
* The rows of a registry that carry an id: exactly one, none, or several. The one way to address a
|
|
512
|
+
* study by its id — a caller that wants "the study P1-S01" gets a row only when there is exactly one,
|
|
513
|
+
* so no command can act on the first of two rows by accident. A registry may still hold two rows of
|
|
514
|
+
* one id (`repro/study-registered` reports it); this says so instead of choosing.
|
|
515
|
+
*/
|
|
516
|
+
export type StudyLookup =
|
|
517
|
+
| { readonly kind: "one"; readonly row: Study }
|
|
518
|
+
| { readonly kind: "none" }
|
|
519
|
+
| { readonly kind: "ambiguous"; readonly rows: readonly Study[] };
|
|
520
|
+
|
|
521
|
+
/** The rows of a registry that was read; none when there is no registry, or it does not parse. */
|
|
522
|
+
export const studiesOf = (state: RegistryState): readonly Study[] =>
|
|
523
|
+
state.kind === "read" ? state.registry.studies : [];
|
|
524
|
+
|
|
525
|
+
/** The rows of `studies` whose id is `id`, as a `StudyLookup`. */
|
|
526
|
+
export const studyById = (
|
|
527
|
+
studies: readonly Study[],
|
|
528
|
+
id: string,
|
|
529
|
+
): StudyLookup => {
|
|
530
|
+
const [first, ...rest] = studies.filter((s) => s.id === id);
|
|
531
|
+
if (first === undefined) return { kind: "none" };
|
|
532
|
+
return rest.length === 0
|
|
533
|
+
? { kind: "one", row: first }
|
|
534
|
+
: { kind: "ambiguous", rows: [first, ...rest] };
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The key a path inside a study's folder is held under: `<folder>/<path>`. A plan (`StudyInput.plans`,
|
|
539
|
+
* `IndexInput.plans`), the history of its study (`IndexInput.history`) and a result file
|
|
540
|
+
* (`StudyInput.results`) are all found by it.
|
|
541
|
+
*/
|
|
542
|
+
export const folderKey = (folder: string, path: string): string =>
|
|
543
|
+
`${folder}/${path}`;
|
|
544
|
+
|
|
482
545
|
/**
|
|
483
546
|
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
484
547
|
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
485
548
|
*/
|
|
486
549
|
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
487
|
-
planRefs(r).map((p) => p.
|
|
550
|
+
planRefs(r).map((p) => folderKey(p.folder, p.plan));
|
|
488
551
|
|
|
489
|
-
/**
|
|
490
|
-
export
|
|
491
|
-
r: StudyRegistry,
|
|
492
|
-
): readonly {
|
|
493
|
-
readonly key: string;
|
|
552
|
+
/** A plan a row names: where it is read from, and the kind of the study — what its parser needs. */
|
|
553
|
+
export interface PlanRef {
|
|
494
554
|
readonly folder: string;
|
|
555
|
+
readonly plan: string;
|
|
495
556
|
readonly kind: StudyKind;
|
|
496
|
-
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Each plan `planKeys` names. */
|
|
560
|
+
export const planRefs = (r: StudyRegistry): readonly PlanRef[] =>
|
|
497
561
|
r.studies.flatMap((s) =>
|
|
498
562
|
s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
|
|
499
|
-
? [
|
|
563
|
+
? [
|
|
564
|
+
{
|
|
565
|
+
folder: s.folder,
|
|
566
|
+
plan: s.plan,
|
|
567
|
+
kind: s.kind,
|
|
568
|
+
},
|
|
569
|
+
]
|
|
500
570
|
: [],
|
|
501
571
|
);
|
|
502
572
|
|
|
@@ -522,7 +592,7 @@ const badId: RowCheck = ({ r, s }) =>
|
|
|
522
592
|
|
|
523
593
|
/** Reported once, on the first row that holds the id. */
|
|
524
594
|
const duplicateId: RowCheck = (row) =>
|
|
525
|
-
row.r.studies
|
|
595
|
+
studyById(row.r.studies, row.s.id).kind === "ambiguous" &&
|
|
526
596
|
isFirst(row, (o) => o.id)
|
|
527
597
|
? { problem: "duplicate-id", id: row.s.id }
|
|
528
598
|
: null;
|
|
@@ -572,9 +642,23 @@ const resultFile: RowCheck = ({ s, input }) => {
|
|
|
572
642
|
if (path === undefined) return null;
|
|
573
643
|
if (!isInside(path))
|
|
574
644
|
return { problem: "result-file", id: s.id, path, why: "outside" };
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
645
|
+
const found = input.results.get(folderKey(s.folder, path)) ?? {
|
|
646
|
+
kind: "absent",
|
|
647
|
+
};
|
|
648
|
+
switch (found.kind) {
|
|
649
|
+
case "file":
|
|
650
|
+
return null;
|
|
651
|
+
case "linked":
|
|
652
|
+
return {
|
|
653
|
+
problem: "result-file",
|
|
654
|
+
id: s.id,
|
|
655
|
+
path,
|
|
656
|
+
why: "linked",
|
|
657
|
+
link: found.link,
|
|
658
|
+
};
|
|
659
|
+
case "absent":
|
|
660
|
+
return { problem: "result-file", id: s.id, path, why: "missing" };
|
|
661
|
+
}
|
|
578
662
|
};
|
|
579
663
|
|
|
580
664
|
const noSuccessor: RowCheck = ({ r, s }) => {
|
|
@@ -593,13 +677,15 @@ const unknownMeasurement: RowCheck = ({ r, s }) =>
|
|
|
593
677
|
? null
|
|
594
678
|
: { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
|
|
595
679
|
|
|
596
|
-
/** The state of the plan the row
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
|
|
680
|
+
/** The state of the plan `plan` of the row `s` (absent when outside its folder): the one place a plan is looked up. */
|
|
681
|
+
const planStateOf = (input: StudyInput, s: Study, plan: string): PlanState =>
|
|
682
|
+
isInside(plan)
|
|
683
|
+
? (input.plans.get(folderKey(s.folder, plan)) ?? { kind: "absent" })
|
|
601
684
|
: { kind: "absent" };
|
|
602
|
-
|
|
685
|
+
|
|
686
|
+
/** The state of the plan the row names; null when it names none. */
|
|
687
|
+
const planOf = ({ s, input }: Row): PlanState | null =>
|
|
688
|
+
s.plan === undefined ? null : planStateOf(input, s, s.plan);
|
|
603
689
|
|
|
604
690
|
const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
|
|
605
691
|
|
|
@@ -613,6 +699,8 @@ const planFinding = (
|
|
|
613
699
|
switch (state.kind) {
|
|
614
700
|
case "absent":
|
|
615
701
|
return { ...at, why: "missing" };
|
|
702
|
+
case "linked":
|
|
703
|
+
return { ...at, why: "linked", link: state.link };
|
|
616
704
|
case "unreadable":
|
|
617
705
|
return { ...at, why: "unreadable", reasons: state.why };
|
|
618
706
|
case "draft":
|
|
@@ -630,11 +718,7 @@ const badPlan: RowCheck = ({ s, input }) => {
|
|
|
630
718
|
if (path === undefined) return null;
|
|
631
719
|
if (!isInside(path))
|
|
632
720
|
return { problem: "bad-plan", id: s.id, path, why: "outside" };
|
|
633
|
-
return planFinding(
|
|
634
|
-
s,
|
|
635
|
-
path,
|
|
636
|
-
input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
|
|
637
|
-
);
|
|
721
|
+
return planFinding(s, path, planStateOf(input, s, path));
|
|
638
722
|
};
|
|
639
723
|
|
|
640
724
|
/** A plan under its old name: read, and named for renaming. */
|
|
@@ -801,8 +885,8 @@ const cell = (s: string): string =>
|
|
|
801
885
|
|
|
802
886
|
/**
|
|
803
887
|
* What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
|
|
804
|
-
* `StudyInput.plans`), and the history of each decide-first study by
|
|
805
|
-
* listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
888
|
+
* `StudyInput.plans`), and the history of each decide-first study by the same key. A study with no
|
|
889
|
+
* history listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
806
890
|
*/
|
|
807
891
|
export interface IndexInput {
|
|
808
892
|
readonly plans: ReadonlyMap<string, PlanState>;
|
|
@@ -816,10 +900,11 @@ const declaredMode = (
|
|
|
816
900
|
): StudyMode | null => {
|
|
817
901
|
const state =
|
|
818
902
|
s.plan !== undefined && isInside(s.plan)
|
|
819
|
-
? plans.get(
|
|
903
|
+
? plans.get(folderKey(s.folder, s.plan))
|
|
820
904
|
: undefined;
|
|
821
905
|
return state === undefined ||
|
|
822
906
|
state.kind === "absent" ||
|
|
907
|
+
state.kind === "linked" ||
|
|
823
908
|
state.kind === "unreadable"
|
|
824
909
|
? null
|
|
825
910
|
: (state.plan.mode ?? null);
|
|
@@ -828,7 +913,10 @@ const declaredMode = (
|
|
|
828
913
|
/** What the index shows of `s` beyond its row. */
|
|
829
914
|
const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
|
|
830
915
|
mode: declaredMode(s, at.plans),
|
|
831
|
-
history:
|
|
916
|
+
history:
|
|
917
|
+
(s.plan === undefined
|
|
918
|
+
? undefined
|
|
919
|
+
: at.history.get(folderKey(s.folder, s.plan))) ?? null,
|
|
832
920
|
});
|
|
833
921
|
|
|
834
922
|
/**
|
|
@@ -1039,7 +1127,9 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
1039
1127
|
"no-result": (f) =>
|
|
1040
1128
|
`${REGISTRY}: ${tick(f.id)} is \`done\` without ${f.missing.map(tick).join(" and ")}. What to do: write the one-line result and the file it is read from (\`result\`, \`result_file\`), or set the status it really has.`,
|
|
1041
1129
|
"result-file": (f) =>
|
|
1042
|
-
|
|
1130
|
+
f.why === "linked"
|
|
1131
|
+
? `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.`
|
|
1132
|
+
: `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
|
|
1043
1133
|
"no-successor": (f) =>
|
|
1044
1134
|
`${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
|
|
1045
1135
|
"stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
|
|
@@ -1059,6 +1149,8 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
1059
1149
|
switch (f.why) {
|
|
1060
1150
|
case "missing":
|
|
1061
1151
|
return `${REGISTRY}: ${of} is not a file in its folder. What to do: write the plan there (its fields are on the rule's page), or correct the row's \`plan\`.`;
|
|
1152
|
+
case "linked":
|
|
1153
|
+
return `${REGISTRY}: ${of} passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.`;
|
|
1062
1154
|
case "outside":
|
|
1063
1155
|
return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
|
|
1064
1156
|
case "unreadable":
|
|
@@ -10,7 +10,6 @@ import { expect, it } from "vitest";
|
|
|
10
10
|
import { gitHistory } from "./adapters/git/index.ts";
|
|
11
11
|
import {
|
|
12
12
|
nodeFiles,
|
|
13
|
-
nodeListDir,
|
|
14
13
|
nodeListEntries,
|
|
15
14
|
spawnProcess,
|
|
16
15
|
} from "./adapters/node/index.ts";
|
|
@@ -42,7 +41,6 @@ const T = (h: number): string =>
|
|
|
42
41
|
const lint = (repo: string, file = "p/PIPELINE-STATUS.md"): string[] => {
|
|
43
42
|
const rule = outputsBeforeDecisionRules({
|
|
44
43
|
files: nodeFiles,
|
|
45
|
-
listDir: nodeListDir,
|
|
46
44
|
entries: nodeListEntries,
|
|
47
45
|
codec: studyRegistryCodec,
|
|
48
46
|
git: gitHistory(spawnProcess(), { PATH: String(process.env.PATH) }),
|
|
@@ -204,3 +202,50 @@ it("never reads through a study folder that is a symbolic link", () => {
|
|
|
204
202
|
// Guards: the outputs behind the link are not this paper's study; nothing is read through it.
|
|
205
203
|
expect(lint(repo)).toEqual([]);
|
|
206
204
|
});
|
|
205
|
+
|
|
206
|
+
it("a plan that is a symbolic link declares no mode: the order of its study is not judged", () => {
|
|
207
|
+
const repo = join(root, "plan-link");
|
|
208
|
+
initRepo(repo);
|
|
209
|
+
mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
|
|
210
|
+
writeFileSync(join(repo, "p/elsewhere/plan.json"), planText(planOf(DECIDED)));
|
|
211
|
+
mkdirSync(join(repo, S), { recursive: true });
|
|
212
|
+
symlinkSync("../../elsewhere/plan.json", join(repo, S, "plan.json"));
|
|
213
|
+
commit(
|
|
214
|
+
repo,
|
|
215
|
+
{
|
|
216
|
+
...Object.fromEntries(
|
|
217
|
+
Object.entries(PAPER).filter(([k]) => k !== `${S}/plan.json`),
|
|
218
|
+
),
|
|
219
|
+
[`${S}/RESULTS.md`]: "41 of 60\n",
|
|
220
|
+
},
|
|
221
|
+
T(9),
|
|
222
|
+
);
|
|
223
|
+
commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
|
|
224
|
+
// Guards: read through the link, the plan is a decide-first one and its output precedes its
|
|
225
|
+
// decision (`output-before-decision`); a link is read through by nothing, so the study declares no mode.
|
|
226
|
+
expect(lint(repo)).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("loud on a decision under a linked directory: git records the link, not the files behind it", () => {
|
|
230
|
+
const repo = join(root, "decision-dir-link");
|
|
231
|
+
initRepo(repo);
|
|
232
|
+
mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
|
|
233
|
+
writeFileSync(join(repo, "p/elsewhere/decision.md"), "exclude run 7\n");
|
|
234
|
+
const file = "repro/P1-S01-outcome/sub/decision.md";
|
|
235
|
+
commit(
|
|
236
|
+
repo,
|
|
237
|
+
{
|
|
238
|
+
...PAPER,
|
|
239
|
+
[`${S}/plan.json`]: planText(
|
|
240
|
+
planOf({ ...DECIDED, decision: { file, note: "n" } }),
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
T(9),
|
|
244
|
+
);
|
|
245
|
+
symlinkSync("../../elsewhere", join(repo, S, "sub"));
|
|
246
|
+
commit(repo, {}, T(10));
|
|
247
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
|
|
248
|
+
// Guards: the decision file exists, and is committed as a path through a link; the order proof
|
|
249
|
+
// would be read from the link's history. The study is reported instead.
|
|
250
|
+
expect(lint(repo)).toEqual(["decision-linked"]);
|
|
251
|
+
});
|
|
@@ -14,13 +14,9 @@
|
|
|
14
14
|
* Reported on `PIPELINE-STATUS.md`, first line, like the other `repro/` rules.
|
|
15
15
|
*/
|
|
16
16
|
import { basename, dirname, join } from "node:path";
|
|
17
|
+
import { filter, map, pipe } from "remeda";
|
|
17
18
|
import { callerPath } from "./caller-path.ts";
|
|
18
|
-
import {
|
|
19
|
-
filesUnder,
|
|
20
|
-
modeStudies,
|
|
21
|
-
onDisk,
|
|
22
|
-
type StudyDeps,
|
|
23
|
-
} from "./study-modes.ts";
|
|
19
|
+
import { decisionEntry, modeStudies, type ModeStudy } from "./study-modes.ts";
|
|
24
20
|
import {
|
|
25
21
|
judgeOrder,
|
|
26
22
|
orderMessage,
|
|
@@ -28,11 +24,15 @@ import {
|
|
|
28
24
|
type OrderFinding,
|
|
29
25
|
} from "./domain/decide-first.ts";
|
|
30
26
|
import { REPRO_DIR } from "./domain/repro-layout.ts";
|
|
27
|
+
import type { DecideFirst } from "./domain/study-mode.ts";
|
|
31
28
|
import type {
|
|
32
29
|
FolderRuleContext,
|
|
33
30
|
FolderRuleModule,
|
|
34
31
|
} from "./folder-venue-rule.ts";
|
|
35
32
|
import type { GitOrder } from "./ports/git-history.ts";
|
|
33
|
+
import type { StudyDeps } from "./ports/study-deps.ts";
|
|
34
|
+
import { studyFolder } from "./study-folder.ts";
|
|
35
|
+
import { registryOf } from "./study-registry-rule.ts";
|
|
36
36
|
import { rulePageUrl } from "./venue-rules.ts";
|
|
37
37
|
|
|
38
38
|
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
@@ -46,32 +46,41 @@ export interface OrderDeps extends StudyDeps {
|
|
|
46
46
|
export const decideFirstStudies = (
|
|
47
47
|
paper: string,
|
|
48
48
|
deps: OrderDeps,
|
|
49
|
-
): readonly DecideFirstStudy[] =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
): readonly DecideFirstStudy[] => {
|
|
50
|
+
const reader = studyFolder(paper, deps);
|
|
51
|
+
return pipe(
|
|
52
|
+
modeStudies(registryOf(paper, deps), reader, deps.codec),
|
|
53
|
+
filter(isDecidedAndReady),
|
|
54
|
+
map(({ row, plan, mode }): DecideFirstStudy => {
|
|
55
|
+
const folder = join(paper, REPRO_DIR, row.folder);
|
|
56
|
+
const decision = join(paper, mode.decision.file);
|
|
57
|
+
const on = decisionEntry(reader, mode.decision.file);
|
|
58
|
+
return {
|
|
58
59
|
id: row.id,
|
|
59
60
|
folder: row.folder,
|
|
60
61
|
plan,
|
|
61
62
|
mode,
|
|
62
|
-
tree:
|
|
63
|
+
tree: reader.list(row.folder),
|
|
63
64
|
facts:
|
|
64
|
-
on === "
|
|
65
|
-
?
|
|
66
|
-
:
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
on.kind === "ok"
|
|
66
|
+
? deps.git.order(callerPath(folder), callerPath(decision))
|
|
67
|
+
: on,
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Only a ready plan decides: a draft may name no outputs, or an empty decision file. */
|
|
74
|
+
const isDecidedAndReady = (
|
|
75
|
+
s: ModeStudy,
|
|
76
|
+
): s is ModeStudy & { readonly mode: DecideFirst } =>
|
|
77
|
+
s.mode.kind === "decide_before_run" && s.empty.length === 0;
|
|
70
78
|
|
|
71
79
|
const PROBLEMS: readonly OrderFinding["problem"][] = [
|
|
72
80
|
"decision-is-output",
|
|
73
81
|
"decision-deleted",
|
|
74
82
|
"decision-not-file",
|
|
83
|
+
"decision-linked",
|
|
75
84
|
"no-history",
|
|
76
85
|
"shallow",
|
|
77
86
|
"undecided-output",
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A directory's entries with what each one is, read without following a symbolic link — so a walk
|
|
3
3
|
* that descends only into `dir` entries can neither loop on a link to an ancestor nor wander into a
|
|
4
|
-
* tree outside through one. The
|
|
4
|
+
* tree outside through one. The reader of a study's folder
|
|
5
|
+
* (`src/study-folder.ts`), outputs listing included (`StudyFolder.list`), reads with it.
|
|
5
6
|
*/
|
|
6
7
|
import type { AbsolutePath } from "../domain/paths.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* One entry of a directory: a directory, a symbolic link (not followed), or
|
|
10
|
-
*
|
|
10
|
+
* One entry of a directory: a regular file, a directory, a symbolic link (not followed), or `other` —
|
|
11
|
+
* anything that is none of those (a FIFO, a socket, a device). An `other` entry is a name and nothing
|
|
12
|
+
* more: it is never read (opening a FIFO blocks) and never descended into, and an outputs listing
|
|
13
|
+
* still names it.
|
|
11
14
|
*/
|
|
12
15
|
export interface DirEntry {
|
|
13
16
|
readonly name: string;
|
|
14
|
-
readonly kind: "file" | "dir" | "symlink";
|
|
17
|
+
readonly kind: "file" | "dir" | "symlink" | "other";
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/** The entries of `dir`; none when it does not exist or is not a directory. */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the readers of a paper's studies are handed: the disk, the entries of a directory with what
|
|
3
|
+
* each is (no symbolic link followed), and the parsers of the registry and its plans. The study folder's
|
|
4
|
+
* reader (`src/study-folder.ts`), the gate and the order rule are built from it.
|
|
5
|
+
*/
|
|
6
|
+
import type { Files } from "./files.ts";
|
|
7
|
+
import type { ListEntries } from "./list-entries.ts";
|
|
8
|
+
import type { StudyRegistryCodec } from "./study-registry-codec.ts";
|
|
9
|
+
|
|
10
|
+
export interface StudyDeps {
|
|
11
|
+
readonly files: Files;
|
|
12
|
+
readonly entries: ListEntries;
|
|
13
|
+
readonly codec: StudyRegistryCodec;
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reads a paper's study folders allow, shaped by what the study readers need: a row's folder is
|
|
3
|
+
* entered or refused, a path inside it is located or read, the files under it are listed, and the
|
|
4
|
+
* decision file's path is walked — none of it by following a symbolic link. The reader of every plan,
|
|
5
|
+
* result file, output listing and decision (`src/study-folder.ts`) is this port's one implementation.
|
|
6
|
+
*/
|
|
7
|
+
import type { Located } from "../domain/study-registry.ts";
|
|
8
|
+
|
|
9
|
+
/** What reading a path inside a study's folder gave. */
|
|
10
|
+
export type FolderRead =
|
|
11
|
+
| { readonly kind: "text"; readonly text: string }
|
|
12
|
+
| { readonly kind: "absent" }
|
|
13
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
14
|
+
|
|
15
|
+
/** What a path in the paper is: `Located`, a directory, or something that is no regular file (a FIFO, a socket). */
|
|
16
|
+
export type PathEntry =
|
|
17
|
+
Located | { readonly kind: "dir" } | { readonly kind: "other" };
|
|
18
|
+
|
|
19
|
+
/** Why a row's folder may not be entered: it is no one folder inside `repro/`, or it is a link. */
|
|
20
|
+
export type FolderRefusal = "not-a-folder" | "symbolic-link";
|
|
21
|
+
|
|
22
|
+
/** The reads a paper's study folders allow. */
|
|
23
|
+
export interface StudyFolder {
|
|
24
|
+
/** Why `folder` may not be entered; null when it may. */
|
|
25
|
+
readonly refusal: (folder: string) => FolderRefusal | null;
|
|
26
|
+
/** What `path` in `folder` is; nothing is read. */
|
|
27
|
+
readonly locate: (folder: string, path: string) => Located;
|
|
28
|
+
/** The text of the file at `path` in `folder`. */
|
|
29
|
+
readonly read: (folder: string, path: string) => FolderRead;
|
|
30
|
+
/**
|
|
31
|
+
* Every file under `folder`, relative to it and `/`-separated: a symbolic link is listed as a file
|
|
32
|
+
* (git stores it as one) and never descended into; a FIFO or a socket is listed by name too (it is
|
|
33
|
+
* never read). None for a folder that may not be entered.
|
|
34
|
+
*/
|
|
35
|
+
readonly list: (folder: string) => readonly string[];
|
|
36
|
+
/** What `path`, relative to the paper, is: a decision file is held to be a regular file. */
|
|
37
|
+
readonly entryAt: (path: string) => PathEntry;
|
|
38
|
+
}
|
package/src/presets.ts
CHANGED
|
@@ -406,8 +406,7 @@ export type ShippedVenue = Pick<
|
|
|
406
406
|
>;
|
|
407
407
|
|
|
408
408
|
/**
|
|
409
|
-
* A resolved preset as a name knows it: its label, its aliases, its chain. Pure.
|
|
410
|
-
* `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
|
|
409
|
+
* A resolved preset as a name knows it: its label, its aliases, its chain. Pure.
|
|
411
410
|
*/
|
|
412
411
|
export const venueOf = ({
|
|
413
412
|
label,
|
|
@@ -98,7 +98,7 @@ it("refuses a registry whose row names a folder outside repro/, for index and fo
|
|
|
98
98
|
{ ...ROW, folder: "../x" },
|
|
99
99
|
);
|
|
100
100
|
const line =
|
|
101
|
-
"P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
|
|
101
|
+
"P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside papers/example/repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
|
|
102
102
|
const index = studies("index", escaping, { title: undefined });
|
|
103
103
|
const added = studies("new", escaping, { title: "Second" });
|
|
104
104
|
// Guards: nothing is written — no README, no new folder — when a row could lead outside the paper.
|
|
@@ -122,7 +122,7 @@ it("refuses a registry whose study folder is a symbolic link, for index", () =>
|
|
|
122
122
|
expect([r.code, r.err, README in r.after]).toEqual([
|
|
123
123
|
1,
|
|
124
124
|
[
|
|
125
|
-
"P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is
|
|
125
|
+
"P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is a symbolic link: nothing is read or written through it. Give the row a real folder inside papers/example/repro/, then run this again.",
|
|
126
126
|
],
|
|
127
127
|
false,
|
|
128
128
|
]);
|
package/src/studies.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* registry is refused with the file to write rather than given a guessed one.
|
|
17
17
|
*/
|
|
18
18
|
import { join, relative, resolve } from "node:path";
|
|
19
|
+
import { find, map, pipe } from "remeda";
|
|
19
20
|
import { callerPath } from "./caller-path.ts";
|
|
20
21
|
import type { AbsolutePath } from "./domain/paths.ts";
|
|
21
22
|
import { README, REPRO_DIR } from "./domain/repro-layout.ts";
|
|
@@ -37,10 +38,12 @@ import {
|
|
|
37
38
|
type StudyRegistry,
|
|
38
39
|
} from "./domain/study-registry.ts";
|
|
39
40
|
import type { Files } from "./ports/files.ts";
|
|
41
|
+
import type { FolderRefusal } from "./ports/study-folder.ts";
|
|
40
42
|
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
41
43
|
import type { ListEntries } from "./ports/list-entries.ts";
|
|
42
44
|
import type { ListDir } from "./ports/talk-media.ts";
|
|
43
|
-
import {
|
|
45
|
+
import { registryOf } from "./study-registry-rule.ts";
|
|
46
|
+
import { folderRefusalText, studyFolder } from "./study-folder.ts";
|
|
44
47
|
import { runGate } from "./study-gate.ts";
|
|
45
48
|
import { indexInputOf } from "./study-index-input.ts";
|
|
46
49
|
import type { GitHistory, GitOrder } from "./ports/git-history.ts";
|
|
@@ -106,20 +109,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
/** The refusal when a row names a folder that
|
|
112
|
+
/** The refusal when a row names a folder that may not be entered; null when none does. */
|
|
110
113
|
function escaping(
|
|
111
114
|
p: Paper,
|
|
112
115
|
r: StudyRegistry,
|
|
113
116
|
deps: StudiesDeps,
|
|
114
117
|
): Refusal | null {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
const folder = studyFolder(p.dir, deps);
|
|
119
|
+
const found = pipe(
|
|
120
|
+
r.studies,
|
|
121
|
+
map((row) => ({ row, why: folder.refusal(row.folder) })),
|
|
122
|
+
find(
|
|
123
|
+
(x): x is typeof x & { readonly why: FolderRefusal } => x.why !== null,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
if (found === undefined) return null;
|
|
127
|
+
const { reason, fix } = folderRefusalText(found.why, p.shown);
|
|
128
|
+
return refuse(
|
|
129
|
+
1,
|
|
130
|
+
`${found.row.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${found.row.folder}\`, which ${reason}: nothing is read or written through it. ${fix}, then run this again.`,
|
|
131
|
+
);
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
const isRefusal = (v: unknown): v is Refusal =>
|