aid-installer 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -642,6 +642,301 @@ function deriveKbStatus(kbDir, summaryApproved, summaryPresent, kbBaseline, repo
642
642
  }
643
643
  }
644
644
 
645
+ // ---------------------------------------------------------------------------
646
+ // f007 / task-042: per-doc freshness (byte-parity twin of derivation.py)
647
+ // ---------------------------------------------------------------------------
648
+
649
+ const RE_FM_FENCE = /^---\s*$/;
650
+ const RE_URL_SOURCE = /^[a-z][a-z0-9+.\-]*:\/\//;
651
+
652
+ function isUrlSource(entry) {
653
+ // Twin of Python parsers.is_url_source() and kb-freshness-check.sh is_url().
654
+ return RE_URL_SOURCE.test(entry);
655
+ }
656
+
657
+ function parseDocFrontmatter(docPath) {
658
+ // Tolerant sources:/approved_at_commit: frontmatter scan for one KB doc.
659
+ // Twin of Python parsers.parse_doc_frontmatter().
660
+ //
661
+ // Returns [approvedAtCommit, sourcesList, sourcesFieldPresent].
662
+ // approvedAtCommit: string or null
663
+ // sourcesList: string[] (items from sources: list)
664
+ // sourcesFieldPresent: boolean (true even if sources: [])
665
+ //
666
+ // Never throws. Handles inline list [a,b] + block list (- a\n - b).
667
+
668
+ let approvedAtCommit = null;
669
+ const sourcesList = [];
670
+ let sourcesFieldPresent = false;
671
+
672
+ let raw;
673
+ try {
674
+ raw = readFileSync(docPath, "utf-8");
675
+ } catch (_) {
676
+ return [null, [], false];
677
+ }
678
+
679
+ const lines = raw.split("\n");
680
+ let inFm = false;
681
+ let fmEntered = false;
682
+ let inSourcesBlock = false;
683
+
684
+ for (const line of lines) {
685
+ const stripped = line.replace(/\r$/, "");
686
+ if (RE_FM_FENCE.test(stripped)) {
687
+ if (!fmEntered) {
688
+ inFm = true;
689
+ fmEntered = true;
690
+ continue;
691
+ } else {
692
+ break;
693
+ }
694
+ }
695
+ if (!inFm) {
696
+ break;
697
+ }
698
+
699
+ if (inSourcesBlock) {
700
+ // Block-list item: leading whitespace + '-'
701
+ const mItem = /^[ \t]+-[ \t]*(.*)/.exec(stripped);
702
+ if (mItem) {
703
+ const item = mItem[1].trim().replace(/^['"]|['"]$/g, "");
704
+ if (item) sourcesList.push(item);
705
+ continue;
706
+ } else {
707
+ inSourcesBlock = false;
708
+ // fall through to check this line for other fields
709
+ }
710
+ }
711
+
712
+ // approved_at_commit: scalar
713
+ const mAac = /^approved_at_commit:\s*(.*)/.exec(stripped);
714
+ if (mAac) {
715
+ const val = mAac[1].trim().replace(/^['"]|['"]$/g, "");
716
+ approvedAtCommit = val || null;
717
+ continue;
718
+ }
719
+
720
+ // sources: field
721
+ const mSrc = /^sources:\s*(.*)/.exec(stripped);
722
+ if (mSrc) {
723
+ sourcesFieldPresent = true;
724
+ const rest = mSrc[1].trim();
725
+ if (rest === "[]") {
726
+ // Explicit empty inline list: sources: []
727
+ // sourcesFieldPresent already set; list stays empty
728
+ continue;
729
+ }
730
+ if (!rest) {
731
+ // Bare 'sources:' with nothing after -- block list follows
732
+ inSourcesBlock = true;
733
+ continue;
734
+ }
735
+ if (rest.startsWith("[")) {
736
+ // Inline list: [a, b, c]
737
+ const inner = rest.replace(/^\[/, "").replace(/\].*$/, "").trim();
738
+ if (inner) {
739
+ for (const item of inner.split(",")) {
740
+ const s = item.trim().replace(/^['"]|['"]$/g, "");
741
+ if (s) sourcesList.push(s);
742
+ }
743
+ }
744
+ continue;
745
+ }
746
+ // Block list -- following indented lines are items
747
+ inSourcesBlock = true;
748
+ continue;
749
+ }
750
+ }
751
+
752
+ return [approvedAtCommit, sourcesList, sourcesFieldPresent];
753
+ }
754
+
755
+ function _readRoutingFields(docPath) {
756
+ // Read kb-category and source frontmatter scalars for doc routing.
757
+ // Returns [kbCategory, sourceField]. Absent fields return "".
758
+ // Twin of Python derivation._read_routing_fields().
759
+ // Never throws.
760
+
761
+ let raw;
762
+ try {
763
+ raw = readFileSync(docPath, "utf-8");
764
+ } catch (_) {
765
+ return ["", ""];
766
+ }
767
+
768
+ const lines = raw.split("\n");
769
+ let inFm = false;
770
+ let fmEntered = false;
771
+ let kbCat = "";
772
+ let srcField = "";
773
+
774
+ for (const line of lines) {
775
+ const stripped = line.replace(/\r$/, "");
776
+ if (RE_FM_FENCE.test(stripped)) {
777
+ if (!fmEntered) {
778
+ inFm = true;
779
+ fmEntered = true;
780
+ continue;
781
+ } else {
782
+ break;
783
+ }
784
+ }
785
+ if (!inFm) break;
786
+
787
+ const mCat = /^kb-category:\s*(.*)/.exec(stripped);
788
+ if (mCat) {
789
+ kbCat = mCat[1].trim().replace(/^['"]|['"]$/g, "");
790
+ continue;
791
+ }
792
+ const mSrc = /^source:\s*(.*)/.exec(stripped);
793
+ if (mSrc) {
794
+ srcField = mSrc[1].trim().replace(/^['"]|['"]$/g, "");
795
+ continue;
796
+ }
797
+ }
798
+
799
+ return [kbCat, srcField];
800
+ }
801
+
802
+ function _runMergeBaseIsAncestor(repoRoot, cSrc, baseline) {
803
+ // Returns "current" | "suspect" | "unknown".
804
+ // execFileSync throws on non-zero exit; status 1 = NOT ancestor = suspect.
805
+ // Any other error (128 bad object, ENOENT, timeout) = unknown.
806
+ try {
807
+ execFileSync(
808
+ "git",
809
+ ["-C", repoRoot, "merge-base", "--is-ancestor", cSrc, baseline],
810
+ {
811
+ timeout: GIT_TIMEOUT_MS,
812
+ stdio: ["ignore", "pipe", "pipe"],
813
+ encoding: "utf-8",
814
+ }
815
+ );
816
+ // exit 0 = ancestor/equal = current
817
+ return "current";
818
+ } catch (err) {
819
+ // execFileSync throws with err.status for non-zero exit
820
+ if (err && err.status === 1) {
821
+ // exit 1 = NOT ancestor = source changed after baseline
822
+ return "suspect";
823
+ }
824
+ // exit 128 (bad object), ENOENT (git absent), timeout, etc. -> unknown
825
+ return "unknown";
826
+ }
827
+ }
828
+
829
+ const SKIP_NAMES = new Set(["INDEX.md", "README.md", "STATE.md"]);
830
+
831
+ function deriveDocFreshness(kbDir, repoRoot) {
832
+ // f007: Per-doc freshness read for all hand-authored primary/extension KB docs.
833
+ // Twin of Python derivation.derive_doc_freshness().
834
+ //
835
+ // Same algorithm as kb-freshness-check.sh (task-040):
836
+ // - Same doc routing (skip INDEX.md, README.md, STATE.md, meta, generated)
837
+ // - Same absence gate (no approved_at_commit: -> unknown; no/empty sources: -> current)
838
+ // - Same git verbs: git log -1 --format=%H -- <src> + merge-base --is-ancestor
839
+ // - Same fold rule: suspect > current > unknown
840
+ // - Same degrade-to-unknown matrix (any git failure -> unknown, never false suspect)
841
+ //
842
+ // Returns array of {doc, verdict, suspect_sources} sorted by doc path.
843
+ // Never throws. No writes.
844
+
845
+ const results = [];
846
+
847
+ let isDir = false;
848
+ try { isDir = statSync(kbDir).isDirectory(); } catch (_) { isDir = false; }
849
+ if (!isDir) return results;
850
+
851
+ let entries = [];
852
+ try { entries = readdirSync(kbDir); } catch (_) { return results; }
853
+
854
+ // Sort deterministically (same as Python sorted() + bash sort)
855
+ const mdFiles = entries
856
+ .filter(n => n.endsWith(".md") && !n.startsWith("."))
857
+ .sort();
858
+
859
+ for (const name of mdFiles) {
860
+ if (SKIP_NAMES.has(name)) continue;
861
+
862
+ const docPath = join(kbDir, name);
863
+
864
+ // Check routing fields: kb-category and source
865
+ const [kbCat, srcField] = _readRoutingFields(docPath);
866
+ if (kbCat === "meta") continue;
867
+ if (srcField === "generated") continue;
868
+ // Only primary and extension with hand-authored (or absent) source
869
+ if (kbCat !== "primary" && kbCat !== "extension" && kbCat !== "") continue;
870
+
871
+ const rel = name;
872
+
873
+ // Parse frontmatter: approved_at_commit + sources
874
+ const [approvedAtCommit, sourcesList, sourcesFieldPresent] =
875
+ parseDocFrontmatter(docPath);
876
+
877
+ // Absence gate: missing/empty approved_at_commit -> unknown (never suspect)
878
+ if (!approvedAtCommit) {
879
+ results.push({ doc: rel, verdict: "unknown", suspect_sources: [] });
880
+ continue;
881
+ }
882
+
883
+ // sources: absent or empty -> current (nothing to drift against)
884
+ if (!sourcesFieldPresent || sourcesList.length === 0) {
885
+ results.push({ doc: rel, verdict: "current", suspect_sources: [] });
886
+ continue;
887
+ }
888
+
889
+ // Per-source staleness checks
890
+ let nCurrent = 0;
891
+ let nSuspect = 0;
892
+ let nUnknown = 0;
893
+ const suspectSources = [];
894
+
895
+ for (const entry of sourcesList) {
896
+ const srcVerdict = _checkSourceNode(entry, approvedAtCommit, repoRoot);
897
+ if (srcVerdict === "current") {
898
+ nCurrent++;
899
+ } else if (srcVerdict === "suspect") {
900
+ nSuspect++;
901
+ suspectSources.push(entry);
902
+ } else {
903
+ nUnknown++;
904
+ }
905
+ }
906
+
907
+ // Fold rule (identical to script and Python twin)
908
+ let verdict;
909
+ if (nSuspect > 0) {
910
+ verdict = "suspect";
911
+ } else if (nCurrent > 0) {
912
+ verdict = "current";
913
+ } else {
914
+ verdict = "unknown";
915
+ }
916
+
917
+ results.push({ doc: rel, verdict, suspect_sources: suspectSources });
918
+ }
919
+
920
+ return results;
921
+ }
922
+
923
+ function _checkSourceNode(entry, approvedAtCommit, repoRoot) {
924
+ // Node implementation of check_source (twin of Python _check_source).
925
+ // Returns "current" | "suspect" | "unknown".
926
+
927
+ if (isUrlSource(entry)) return "unknown";
928
+
929
+ // Get last-changed commit for this path/glob
930
+ const cSrc = runGitCommand(
931
+ ["-C", repoRoot, "log", "-1", "--format=%H", "--", entry],
932
+ null
933
+ );
934
+ if (!cSrc) return "unknown";
935
+
936
+ // merge-base --is-ancestor: exit 0 = current, exit 1 = suspect, other = unknown
937
+ return _runMergeBaseIsAncestor(repoRoot, cSrc, approvedAtCommit);
938
+ }
939
+
645
940
  // ---------------------------------------------------------------------------
646
941
  // Derivation helpers (mirrors derivation.py)
647
942
  // ---------------------------------------------------------------------------
@@ -1798,6 +2093,11 @@ function _readRepoFull(root) {
1798
2093
  );
1799
2094
  kbState.status = kbStatus;
1800
2095
  kbState.kb_baseline = kbBaseline;
2096
+
2097
+ // task-042: per-doc freshness (f007) -- additive; gitFreshnessCheck retained
2098
+ const docFreshness = deriveDocFreshness(loc.kbDir, resolvedRoot);
2099
+ kbState.doc_freshness = docFreshness;
2100
+ kbState.suspect_count = docFreshness.filter(d => d.verdict === "suspect").length;
1801
2101
  }
1802
2102
 
1803
2103
  const repoInfo = { project_name: projectName, aid_dir: loc.aidDir, kb_state: kbState };
@@ -3028,6 +3328,7 @@ function _buildKbStateRef(kb) {
3028
3328
  // KbStateRef field order (DM-A3 deterministic, task-064):
3029
3329
  // retained: summary_approved, last_summary_date, doc_count
3030
3330
  // new: status, summary_present, kb_baseline
3331
+ // task-042 additions: doc_freshness, suspect_count
3031
3332
  return {
3032
3333
  summary_approved: kb.summary_approved,
3033
3334
  last_summary_date: kb.last_summary_date,
@@ -3035,6 +3336,18 @@ function _buildKbStateRef(kb) {
3035
3336
  status: kb.status !== undefined ? kb.status : KbStatus.unknown,
3036
3337
  summary_present: kb.summary_present !== undefined ? kb.summary_present : false,
3037
3338
  kb_baseline: _buildKbBaseline(kb.kb_baseline),
3339
+ doc_freshness: Array.isArray(kb.doc_freshness) ? kb.doc_freshness.map(_buildDocFreshness) : [],
3340
+ suspect_count: typeof kb.suspect_count === "number" ? kb.suspect_count : 0,
3341
+ };
3342
+ }
3343
+
3344
+ function _buildDocFreshness(df) {
3345
+ // DocFreshness field order: doc, verdict, suspect_sources
3346
+ // Twin of Python DocFreshness dataclass (models.py, task-042).
3347
+ return {
3348
+ doc: df.doc,
3349
+ verdict: df.verdict,
3350
+ suspect_sources: Array.isArray(df.suspect_sources) ? df.suspect_sources : [],
3038
3351
  };
3039
3352
  }
3040
3353
 
@@ -483,12 +483,22 @@ def _ser_kb_baseline(obj) -> dict | None:
483
483
  }
484
484
 
485
485
 
486
+ def _ser_doc_freshness(obj) -> dict:
487
+ """Serialize one DocFreshness entry in declared field order (task-042/task-043)."""
488
+ return {
489
+ "doc": obj.doc,
490
+ "verdict": obj.verdict,
491
+ "suspect_sources": list(obj.suspect_sources),
492
+ }
493
+
494
+
486
495
  def _ser_kb_state(obj) -> dict | None:
487
496
  """Serialize KbStateRef in declared field order (DM-A3, task-064), or None if absent.
488
497
 
489
498
  Field order (DM-3 deterministic):
490
499
  retained: summary_approved, last_summary_date, doc_count
491
500
  new (task-064): status, summary_present, kb_baseline
501
+ new (task-042/task-043): doc_freshness, suspect_count
492
502
  No schema_version bump (DM-A3).
493
503
  """
494
504
  if obj is None:
@@ -500,6 +510,8 @@ def _ser_kb_state(obj) -> dict | None:
500
510
  "status": obj.status.value if hasattr(obj.status, "value") else str(obj.status),
501
511
  "summary_present": obj.summary_present,
502
512
  "kb_baseline": _ser_kb_baseline(obj.kb_baseline),
513
+ "doc_freshness": [_ser_doc_freshness(d) for d in (obj.doc_freshness or [])],
514
+ "suspect_count": obj.suspect_count if isinstance(obj.suspect_count, int) else 0,
503
515
  }
504
516
 
505
517